feat(payments): payment-batch API surface (betalfil 2/3) (#1504)
Preview/create/list/get/file/cancel routes under /api/supplier-invoices/payment-batches, SI_BATCH_* structured errors, and a fail-closed batch-membership pre-check in the supplier invoice DELETE route (the FK RESTRICT is the backstop). File downloads stamp file_generated_at + download_count but regenerate byte-identically. 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
f776e375c5
commit
576a34750a
@@ -252,6 +252,28 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
})
|
||||
}
|
||||
|
||||
// A payment-batch item documents a payment instruction possibly already
|
||||
// handed to the bank; supplier_payment_batch_items.supplier_invoice_id is
|
||||
// ON DELETE RESTRICT, so like (c) the invoice DELETE would fail AFTER the
|
||||
// items were deleted. Same fail-closed rule as the lookups above.
|
||||
const { data: linkedBatchItem, error: batchLookupError } = await supabase
|
||||
.from('supplier_payment_batch_items')
|
||||
.select('id, batch_id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('supplier_invoice_id', id)
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
if (batchLookupError) {
|
||||
return NextResponse.json({ error: getUserErrorMessage(batchLookupError) }, { status: 500 })
|
||||
}
|
||||
|
||||
if (linkedBatchItem) {
|
||||
return errorResponseFromCode('SI_DELETE_IN_PAYMENT_BATCH', log, {
|
||||
details: { batchId: linkedBatchItem.batch_id },
|
||||
})
|
||||
}
|
||||
|
||||
// Delete items first, then invoice
|
||||
await supabase.from('supplier_invoice_items').delete().eq('supplier_invoice_id', id)
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
|
||||
/**
|
||||
* Cancel a payment batch. Compare-and-set on status='created' so two racing
|
||||
* cancels resolve to exactly one winner; the loser gets ALREADY_CANCELLED.
|
||||
*
|
||||
* Cancelling only changes what Accounted will re-serve: a file already
|
||||
* uploaded to the bank is not recalled by this. The confirm dialog says so.
|
||||
*/
|
||||
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'supplier_invoice.payment_batch.cancel',
|
||||
async (_request, { supabase, companyId, user, log, requestId }, { params }) => {
|
||||
const { id } = await params
|
||||
|
||||
const { data: cancelled } = await supabase
|
||||
.from('supplier_payment_batches')
|
||||
.update({
|
||||
status: 'cancelled',
|
||||
cancelled_at: new Date().toISOString(),
|
||||
cancelled_by: user.id,
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'created')
|
||||
.select('id, status, cancelled_at')
|
||||
.single()
|
||||
|
||||
if (cancelled) {
|
||||
return NextResponse.json({ data: cancelled })
|
||||
}
|
||||
|
||||
const { data: existing } = await supabase
|
||||
.from('supplier_payment_batches')
|
||||
.select('id, status')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!existing) {
|
||||
return errorResponseFromCode('SI_BATCH_NOT_FOUND', log, { requestId })
|
||||
}
|
||||
return errorResponseFromCode('SI_BATCH_ALREADY_CANCELLED', log, { requestId })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { renderSupplierPaymentBatchFile } from '@/lib/payments/batch-service'
|
||||
import type { SupplierPaymentBatch, SupplierPaymentBatchItem } from '@/types'
|
||||
|
||||
/**
|
||||
* Download the payment file for a batch.
|
||||
*
|
||||
* The file regenerates deterministically from the stored batch + item rows:
|
||||
* msg_id and created_at were fixed at creation, so every download is
|
||||
* byte-identical and the bank's duplicate detection (keyed on MsgId) stays
|
||||
* meaningful. requireWrite because the download stamps file_generated_at and
|
||||
* bumps download_count (the tax payment-file route sets the precedent).
|
||||
*
|
||||
* Per BFL the generated file is räkenskapsinformation (underlag) for the
|
||||
* payments it initiates; the batch rows it derives from are retained.
|
||||
*/
|
||||
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'supplier_invoice.payment_batch.file',
|
||||
async (_request, { supabase, companyId, log, requestId }, { params }) => {
|
||||
const { id } = await params
|
||||
|
||||
const { data: batch } = await supabase
|
||||
.from('supplier_payment_batches')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!batch) {
|
||||
return errorResponseFromCode('SI_BATCH_NOT_FOUND', log, { requestId })
|
||||
}
|
||||
if ((batch as SupplierPaymentBatch).status === 'cancelled') {
|
||||
return errorResponseFromCode('SI_BATCH_CANCELLED', log, { requestId })
|
||||
}
|
||||
|
||||
const { data: items } = await supabase
|
||||
.from('supplier_payment_batch_items')
|
||||
.select('*')
|
||||
.eq('batch_id', id)
|
||||
.eq('company_id', companyId)
|
||||
.order('created_at', { ascending: true })
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
return errorResponseFromCode('SI_BATCH_NOT_FOUND', log, { requestId })
|
||||
}
|
||||
|
||||
const rendered = renderSupplierPaymentBatchFile(
|
||||
batch as SupplierPaymentBatch,
|
||||
items as SupplierPaymentBatchItem[],
|
||||
)
|
||||
|
||||
await supabase
|
||||
.from('supplier_payment_batches')
|
||||
.update({
|
||||
file_generated_at: new Date().toISOString(),
|
||||
download_count: ((batch as SupplierPaymentBatch).download_count ?? 0) + 1,
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
return new Response(rendered.content, {
|
||||
headers: {
|
||||
'Content-Type': rendered.contentType,
|
||||
'Content-Disposition': `attachment; filename="${rendered.filename}"`,
|
||||
},
|
||||
})
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
|
||||
/**
|
||||
* Batch detail: the batch row plus its items joined to the live invoice state
|
||||
* (status + remaining), so the view can show per-line settlement without any
|
||||
* stored progress that could go stale.
|
||||
*/
|
||||
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'supplier_invoice.payment_batch.get',
|
||||
async (_request, { supabase, companyId, log, requestId }, { params }) => {
|
||||
const { id } = await params
|
||||
|
||||
const { data: batch } = await supabase
|
||||
.from('supplier_payment_batches')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!batch) {
|
||||
return errorResponseFromCode('SI_BATCH_NOT_FOUND', log, { requestId })
|
||||
}
|
||||
|
||||
const { data: items } = await supabase
|
||||
.from('supplier_payment_batch_items')
|
||||
.select(
|
||||
'*, invoice:supplier_invoices(id, status, remaining_amount, supplier_invoice_number, arrival_number)',
|
||||
)
|
||||
.eq('batch_id', id)
|
||||
.eq('company_id', companyId)
|
||||
.order('created_at', { ascending: true })
|
||||
|
||||
return NextResponse.json({ data: { ...batch, items: items ?? [] } })
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,461 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createQueuedMockSupabase,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, enqueueMany, reset, findCall } =
|
||||
createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
import { GET as listBatches, POST as createBatch } from '../route'
|
||||
import { POST as previewBatch } from '../preview/route'
|
||||
import { GET as getBatch } from '../[id]/route'
|
||||
import { GET as downloadFile } from '../[id]/file/route'
|
||||
import { POST as cancelBatch } from '../[id]/cancel/route'
|
||||
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
const UUID_A = '11111111-1111-4111-8111-111111111111'
|
||||
const UUID_B = '22222222-2222-4222-8222-222222222222'
|
||||
const BATCH_ID = 'b1111111-1111-4111-8111-111111111111'
|
||||
|
||||
const params = (id: string) => ({ params: Promise.resolve({ id }) })
|
||||
|
||||
const companyRow = { name: 'Testbolaget AB', org_number: '556677-8899' }
|
||||
const settingsRow = {
|
||||
company_name: 'Testbolaget AB',
|
||||
iban: 'SE3550000000054910000003',
|
||||
bic: 'ESSESESS',
|
||||
clearing_number: null,
|
||||
bank_name: null,
|
||||
}
|
||||
|
||||
function invoiceRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: UUID_A,
|
||||
status: 'approved',
|
||||
approved_at: '2026-08-01T10:00:00Z',
|
||||
due_date: '2099-08-20',
|
||||
remaining_amount: 737.5,
|
||||
currency: 'SEK',
|
||||
is_credit_note: false,
|
||||
payment_reference: null,
|
||||
supplier_invoice_number: 'CD3014794407',
|
||||
supplier: {
|
||||
id: 'sup-1',
|
||||
name: 'Derome Bygg & Industri AB',
|
||||
bankgiro: '5050-1055',
|
||||
plusgiro: null,
|
||||
bank_account: null,
|
||||
clearing_number: null,
|
||||
account_number: null,
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function batchRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: BATCH_ID,
|
||||
company_id: 'company-1',
|
||||
user_id: 'user-1',
|
||||
format: 'pain001',
|
||||
status: 'created',
|
||||
currency: 'SEK',
|
||||
total_amount: 737.5,
|
||||
item_count: 1,
|
||||
msg_id: 'ACCOUNTED-5566778899-BB1111111',
|
||||
debtor_snapshot: {
|
||||
name: 'Testbolaget AB',
|
||||
org_number: '556677-8899',
|
||||
iban: 'SE3550000000054910000003',
|
||||
bic: 'ESSESESS',
|
||||
},
|
||||
file_generated_at: null,
|
||||
download_count: 0,
|
||||
cancelled_at: null,
|
||||
cancelled_by: null,
|
||||
created_at: '2026-08-10T12:00:00Z',
|
||||
updated_at: '2026-08-10T12:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function itemRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'item-1',
|
||||
batch_id: BATCH_ID,
|
||||
company_id: 'company-1',
|
||||
supplier_invoice_id: UUID_A,
|
||||
amount: 737.5,
|
||||
payment_date: '2026-08-15',
|
||||
payee_type: 'bankgiro',
|
||||
payee_bankgiro: '50501055',
|
||||
payee_plusgiro: null,
|
||||
payee_clearing: null,
|
||||
payee_account: null,
|
||||
payee_name: 'Derome Bygg & Industri AB',
|
||||
reference_type: 'invoice_number',
|
||||
reference: 'CD3014794407',
|
||||
created_at: '2026-08-10T12:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
describe('POST /api/supplier-invoices/payment-batches/preview', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
const response = await previewBatch(
|
||||
createMockRequest('/api/supplier-invoices/payment-batches/preview', {
|
||||
method: 'POST',
|
||||
body: { format: 'pain001', ids: [UUID_A] },
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 on an invalid body', async () => {
|
||||
const response = await previewBatch(
|
||||
createMockRequest('/api/supplier-invoices/payment-batches/preview', {
|
||||
method: 'POST',
|
||||
body: { format: 'bg_lb', ids: [UUID_A] },
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns the preview with eligible and excluded lines', async () => {
|
||||
enqueueMany([
|
||||
{ data: [invoiceRow()] },
|
||||
{ data: [] },
|
||||
{ data: companyRow },
|
||||
{ data: settingsRow },
|
||||
])
|
||||
const response = await previewBatch(
|
||||
createMockRequest('/api/supplier-invoices/payment-batches/preview', {
|
||||
method: 'POST',
|
||||
body: { format: 'pain001', ids: [UUID_A, UUID_B] },
|
||||
}),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { eligible: unknown[]; excluded: unknown[]; total: number; debtor_ok: boolean }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.eligible).toHaveLength(1)
|
||||
expect(body.data.excluded).toEqual([{ id: UUID_B, reason: 'not_found' }])
|
||||
expect(body.data.total).toBe(737.5)
|
||||
expect(body.data.debtor_ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/supplier-invoices/payment-batches', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
const response = await createBatch(
|
||||
createMockRequest('/api/supplier-invoices/payment-batches', {
|
||||
method: 'POST',
|
||||
body: { format: 'pain001', items: [{ supplier_invoice_id: UUID_A }] },
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 on an empty item list', async () => {
|
||||
const response = await createBatch(
|
||||
createMockRequest('/api/supplier-invoices/payment-batches', {
|
||||
method: 'POST',
|
||||
body: { format: 'pain001', items: [] },
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('creates a batch and returns 201', async () => {
|
||||
enqueueMany([
|
||||
{ data: companyRow },
|
||||
{ data: settingsRow },
|
||||
{ data: [invoiceRow()] },
|
||||
{ data: [] },
|
||||
{ data: batchRow() },
|
||||
{ data: null },
|
||||
])
|
||||
const response = await createBatch(
|
||||
createMockRequest('/api/supplier-invoices/payment-batches', {
|
||||
method: 'POST',
|
||||
body: { format: 'pain001', items: [{ supplier_invoice_id: UUID_A }] },
|
||||
}),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { id: string; msg_id: string; item_count: number }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(201)
|
||||
expect(body.data.id).toBe(BATCH_ID)
|
||||
expect(body.data.item_count).toBe(1)
|
||||
})
|
||||
|
||||
it('maps an ineligible invoice to SI_BATCH_INELIGIBLE_INVOICE', async () => {
|
||||
enqueueMany([
|
||||
{ data: companyRow },
|
||||
{ data: settingsRow },
|
||||
{ data: [invoiceRow({ status: 'paid' })] },
|
||||
{ data: [] },
|
||||
])
|
||||
const response = await createBatch(
|
||||
createMockRequest('/api/supplier-invoices/payment-batches', {
|
||||
method: 'POST',
|
||||
body: { format: 'pain001', items: [{ supplier_invoice_id: UUID_A }] },
|
||||
}),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; details: { invoices: unknown[] } }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('SI_BATCH_INELIGIBLE_INVOICE')
|
||||
expect(body.error.details.invoices).toEqual([{ id: UUID_A, reason: 'not_payable' }])
|
||||
})
|
||||
|
||||
it('maps an active-batch collision to SI_BATCH_DUPLICATE_INVOICE (409)', async () => {
|
||||
enqueueMany([
|
||||
{ data: companyRow },
|
||||
{ data: settingsRow },
|
||||
{ data: [invoiceRow()] },
|
||||
{ data: [{ supplier_invoice_id: UUID_A, batch: { id: BATCH_ID, status: 'created' } }] },
|
||||
])
|
||||
const response = await createBatch(
|
||||
createMockRequest('/api/supplier-invoices/payment-batches', {
|
||||
method: 'POST',
|
||||
body: { format: 'pain001', items: [{ supplier_invoice_id: UUID_A }] },
|
||||
}),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('SI_BATCH_DUPLICATE_INVOICE')
|
||||
})
|
||||
|
||||
it('maps missing debtor details to SI_BATCH_DEBTOR_INCOMPLETE', async () => {
|
||||
enqueueMany([{ data: companyRow }, { data: { ...settingsRow, iban: null } }])
|
||||
const response = await createBatch(
|
||||
createMockRequest('/api/supplier-invoices/payment-batches', {
|
||||
method: 'POST',
|
||||
body: { format: 'pain001', items: [{ supplier_invoice_id: UUID_A }] },
|
||||
}),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; details: { missing: string } }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('SI_BATCH_DEBTOR_INCOMPLETE')
|
||||
expect(body.error.details.missing).toBe('iban')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/supplier-invoices/payment-batches', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
const response = await listBatches(
|
||||
createMockRequest('/api/supplier-invoices/payment-batches'),
|
||||
)
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns batches with derived settled_count and member invoice ids', async () => {
|
||||
enqueueMany([
|
||||
{ data: [batchRow()] },
|
||||
{
|
||||
data: [
|
||||
{
|
||||
batch_id: BATCH_ID,
|
||||
supplier_invoice_id: UUID_A,
|
||||
invoice: { remaining_amount: 0 },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
const response = await listBatches(
|
||||
createMockRequest('/api/supplier-invoices/payment-batches', {
|
||||
searchParams: { status: 'created' },
|
||||
}),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: Array<{ id: string; settled_count: number; supplier_invoice_ids: string[] }>
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toHaveLength(1)
|
||||
expect(body.data[0].settled_count).toBe(1)
|
||||
expect(body.data[0].supplier_invoice_ids).toEqual([UUID_A])
|
||||
})
|
||||
|
||||
it('rejects an invalid status filter', async () => {
|
||||
const response = await listBatches(
|
||||
createMockRequest('/api/supplier-invoices/payment-batches', {
|
||||
searchParams: { status: 'nonsense' },
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/supplier-invoices/payment-batches/[id]', () => {
|
||||
it('returns 404 for an unknown batch', async () => {
|
||||
enqueue({ data: null })
|
||||
const response = await getBatch(
|
||||
createMockRequest(`/api/supplier-invoices/payment-batches/${BATCH_ID}`),
|
||||
params(BATCH_ID),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
expect(status).toBe(404)
|
||||
expect(body.error.code).toBe('SI_BATCH_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('returns the batch with items and live invoice state', async () => {
|
||||
enqueueMany([
|
||||
{ data: batchRow() },
|
||||
{
|
||||
data: [
|
||||
{
|
||||
...itemRow(),
|
||||
invoice: {
|
||||
id: UUID_A,
|
||||
status: 'approved',
|
||||
remaining_amount: 737.5,
|
||||
supplier_invoice_number: 'CD3014794407',
|
||||
arrival_number: 12,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
const response = await getBatch(
|
||||
createMockRequest(`/api/supplier-invoices/payment-batches/${BATCH_ID}`),
|
||||
params(BATCH_ID),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { id: string; items: unknown[] }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.id).toBe(BATCH_ID)
|
||||
expect(body.data.items).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/supplier-invoices/payment-batches/[id]/file', () => {
|
||||
it('returns 404 for an unknown batch', async () => {
|
||||
enqueue({ data: null })
|
||||
const response = await downloadFile(
|
||||
createMockRequest(`/api/supplier-invoices/payment-batches/${BATCH_ID}/file`),
|
||||
params(BATCH_ID),
|
||||
)
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 409 for a cancelled batch', async () => {
|
||||
enqueue({ data: batchRow({ status: 'cancelled' }) })
|
||||
const response = await downloadFile(
|
||||
createMockRequest(`/api/supplier-invoices/payment-batches/${BATCH_ID}/file`),
|
||||
params(BATCH_ID),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('SI_BATCH_CANCELLED')
|
||||
})
|
||||
|
||||
it('serves the XML attachment and stamps the download', async () => {
|
||||
enqueueMany([
|
||||
{ data: batchRow() },
|
||||
{ data: [itemRow()] },
|
||||
{ data: null },
|
||||
])
|
||||
const response = await downloadFile(
|
||||
createMockRequest(`/api/supplier-invoices/payment-batches/${BATCH_ID}/file`),
|
||||
params(BATCH_ID),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toBe('application/xml; charset=utf-8')
|
||||
expect(response.headers.get('Content-Disposition')).toBe(
|
||||
'attachment; filename="betalfil_20260810_b1111111.xml"',
|
||||
)
|
||||
const xml = await response.text()
|
||||
expect(xml).toContain('<MsgId>ACCOUNTED-5566778899-BB1111111</MsgId>')
|
||||
expect(xml).toContain('<MmbId>9900</MmbId>')
|
||||
|
||||
const stamp = findCall('supplier_payment_batches', 'update')?.[0] as Record<string, unknown>
|
||||
expect(stamp.download_count).toBe(1)
|
||||
expect(stamp.file_generated_at).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/supplier-invoices/payment-batches/[id]/cancel', () => {
|
||||
it('cancels an active batch', async () => {
|
||||
enqueue({
|
||||
data: { id: BATCH_ID, status: 'cancelled', cancelled_at: '2026-08-10T13:00:00Z' },
|
||||
})
|
||||
const response = await cancelBatch(
|
||||
createMockRequest(`/api/supplier-invoices/payment-batches/${BATCH_ID}/cancel`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
params(BATCH_ID),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ data: { status: string } }>(response)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.status).toBe('cancelled')
|
||||
})
|
||||
|
||||
it('returns ALREADY_CANCELLED when the CAS update matches nothing but the batch exists', async () => {
|
||||
enqueueMany([
|
||||
{ data: null },
|
||||
{ data: batchRow({ status: 'cancelled' }) },
|
||||
])
|
||||
const response = await cancelBatch(
|
||||
createMockRequest(`/api/supplier-invoices/payment-batches/${BATCH_ID}/cancel`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
params(BATCH_ID),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('SI_BATCH_ALREADY_CANCELLED')
|
||||
})
|
||||
|
||||
it('returns 404 when the batch does not exist at all', async () => {
|
||||
enqueueMany([{ data: null }, { data: null }])
|
||||
const response = await cancelBatch(
|
||||
createMockRequest(`/api/supplier-invoices/payment-batches/${BATCH_ID}/cancel`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
params(BATCH_ID),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
expect(status).toBe(404)
|
||||
expect(body.error.code).toBe('SI_BATCH_NOT_FOUND')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { PreviewSupplierPaymentBatchSchema } from '@/lib/api/schemas'
|
||||
import { previewSupplierPaymentBatch } from '@/lib/payments/batch-service'
|
||||
|
||||
/**
|
||||
* Preview a payment batch before creating it: which of the selected invoices
|
||||
* are eligible (with per-line defaults, payee, reference and warnings), which
|
||||
* are excluded and why, and whether the company's own bank details (pain.001
|
||||
* debtor) are complete. Same evaluation as create, so the preview can never
|
||||
* promise what create would refuse.
|
||||
*
|
||||
* requireWrite matches create: a viewer role has no business staging payment
|
||||
* instructions it could never create.
|
||||
*/
|
||||
export const POST = withRouteContext(
|
||||
'supplier_invoice.payment_batch.preview',
|
||||
async (request, { supabase, companyId, log }) => {
|
||||
const validation = await validateBody(request, PreviewSupplierPaymentBatchSchema, {
|
||||
log,
|
||||
operation: 'supplier_invoice.payment_batch.preview',
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
|
||||
const preview = await previewSupplierPaymentBatch(supabase, companyId, {
|
||||
ids: validation.data.ids,
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: preview })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -0,0 +1,138 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody, validateQuery } from '@/lib/api/validate'
|
||||
import {
|
||||
CreateSupplierPaymentBatchSchema,
|
||||
SupplierPaymentBatchListQuerySchema,
|
||||
} from '@/lib/api/schemas'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { createSupplierPaymentBatch } from '@/lib/payments/batch-service'
|
||||
import type { SupplierPaymentBatch } from '@/types'
|
||||
|
||||
/**
|
||||
* Create a supplier payment batch (betalfil).
|
||||
*
|
||||
* Creating a batch snapshots payee + reference + amount per invoice and mints
|
||||
* the pain.001 MsgId; it books NOTHING and settles nothing. The client
|
||||
* downloads the file from GET /payment-batches/{id}/file afterwards, and
|
||||
* settlement stays in mark-paid / bank matching once the bank has executed.
|
||||
*/
|
||||
export const POST = withRouteContext(
|
||||
'supplier_invoice.payment_batch.create',
|
||||
async (request, { supabase, companyId, user, log, requestId }) => {
|
||||
const validation = await validateBody(request, CreateSupplierPaymentBatchSchema, {
|
||||
log,
|
||||
operation: 'supplier_invoice.payment_batch.create',
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
|
||||
const result = await createSupplierPaymentBatch(supabase, companyId, user.id, validation.data)
|
||||
|
||||
if (!result.ok) {
|
||||
switch (result.code) {
|
||||
case 'debtor_incomplete':
|
||||
return errorResponseFromCode('SI_BATCH_DEBTOR_INCOMPLETE', log, {
|
||||
requestId,
|
||||
details: { missing: result.missing },
|
||||
})
|
||||
case 'ineligible':
|
||||
return errorResponseFromCode('SI_BATCH_INELIGIBLE_INVOICE', log, {
|
||||
requestId,
|
||||
details: { invoices: result.details },
|
||||
})
|
||||
case 'invalid_amount':
|
||||
return errorResponseFromCode('SI_BATCH_INVALID_AMOUNT', log, {
|
||||
requestId,
|
||||
details: { invoices: result.details },
|
||||
})
|
||||
case 'amount_exceeds_remaining':
|
||||
return errorResponseFromCode('SI_BATCH_AMOUNT_EXCEEDS_REMAINING', log, {
|
||||
requestId,
|
||||
details: { invoices: result.details },
|
||||
})
|
||||
case 'already_batched':
|
||||
return errorResponseFromCode('SI_BATCH_DUPLICATE_INVOICE', log, {
|
||||
requestId,
|
||||
details: { invoices: result.details },
|
||||
})
|
||||
default:
|
||||
return errorResponseFromCode('SI_BATCH_CREATE_FAILED', log, { requestId })
|
||||
}
|
||||
}
|
||||
|
||||
const { batch } = result
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: {
|
||||
id: batch.id,
|
||||
msg_id: batch.msg_id,
|
||||
format: batch.format,
|
||||
total_amount: batch.total_amount,
|
||||
item_count: batch.item_count,
|
||||
created_at: batch.created_at,
|
||||
},
|
||||
},
|
||||
{ status: 201 },
|
||||
)
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
/**
|
||||
* List payment batches. Settlement progress is derived from the live invoice
|
||||
* rows (never stored): settled = remaining_amount at or under the öre epsilon.
|
||||
* For active (created) batches the member invoice ids ride along so the list
|
||||
* page can build its "I betalfil" chip map from one fetch.
|
||||
*/
|
||||
export const GET = withRouteContext(
|
||||
'supplier_invoice.payment_batch.list',
|
||||
async (request, { supabase, companyId }) => {
|
||||
const validation = validateQuery(request, SupplierPaymentBatchListQuerySchema)
|
||||
if (!validation.success) return validation.response
|
||||
const { status, limit, offset } = validation.data
|
||||
|
||||
let query = supabase
|
||||
.from('supplier_payment_batches')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
if (status !== 'all') query = query.eq('status', status)
|
||||
|
||||
const { data: batches, error } = await query
|
||||
if (error) throw error
|
||||
|
||||
const batchRows = (batches ?? []) as SupplierPaymentBatch[]
|
||||
const batchIds = batchRows.map((batch) => batch.id)
|
||||
|
||||
const settledCounts = new Map<string, number>()
|
||||
const invoiceIdsByBatch = new Map<string, string[]>()
|
||||
if (batchIds.length > 0) {
|
||||
const { data: items } = await supabase
|
||||
.from('supplier_payment_batch_items')
|
||||
.select('batch_id, supplier_invoice_id, invoice:supplier_invoices(remaining_amount)')
|
||||
.eq('company_id', companyId)
|
||||
.in('batch_id', batchIds)
|
||||
|
||||
for (const item of items ?? []) {
|
||||
const invoice = item.invoice as unknown as { remaining_amount: number } | null
|
||||
if (invoice && invoice.remaining_amount <= 0.005) {
|
||||
settledCounts.set(item.batch_id, (settledCounts.get(item.batch_id) ?? 0) + 1)
|
||||
}
|
||||
const list = invoiceIdsByBatch.get(item.batch_id)
|
||||
if (list) list.push(item.supplier_invoice_id)
|
||||
else invoiceIdsByBatch.set(item.batch_id, [item.supplier_invoice_id])
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: batchRows.map((batch) => ({
|
||||
...batch,
|
||||
settled_count: settledCounts.get(batch.id) ?? 0,
|
||||
...(batch.status === 'created'
|
||||
? { supplier_invoice_ids: invoiceIdsByBatch.get(batch.id) ?? [] }
|
||||
: {}),
|
||||
})),
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -1081,6 +1081,39 @@ export const UpdateSupplierInvoiceSchema = z.object({
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Supplier payment batch (betalfil) schemas
|
||||
// ============================================================
|
||||
|
||||
// v1 gates the API to pain001; the DB CHECK also allows 'bg_lb' so a future
|
||||
// format lands without a migration.
|
||||
const supplierPaymentBatchFormat = z.enum(['pain001'])
|
||||
|
||||
export const PreviewSupplierPaymentBatchSchema = z.object({
|
||||
format: supplierPaymentBatchFormat,
|
||||
ids: z.array(z.string().uuid()).min(1).max(100),
|
||||
})
|
||||
|
||||
export const SupplierPaymentBatchItemInputSchema = z.object({
|
||||
supplier_invoice_id: z.string().uuid(),
|
||||
// Defaults to the invoice's remaining amount.
|
||||
amount: z.number().positive().optional(),
|
||||
// Defaults to max(due_date, today); past dates are normalized to today.
|
||||
payment_date: isoDate.optional(),
|
||||
})
|
||||
|
||||
export const CreateSupplierPaymentBatchSchema = z.object({
|
||||
format: supplierPaymentBatchFormat,
|
||||
items: z.array(SupplierPaymentBatchItemInputSchema).min(1).max(100),
|
||||
confirm_already_batched: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const SupplierPaymentBatchListQuerySchema = z.object({
|
||||
status: z.enum(['created', 'cancelled', 'all']).default('all'),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Journal entry schemas
|
||||
// ============================================================
|
||||
|
||||
@@ -2191,6 +2191,68 @@ const SUPPLIER_INVOICE_WAVE4: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Kunde inte kreditera leverantörsfakturan.',
|
||||
message_en: 'Failed to credit supplier invoice.',
|
||||
},
|
||||
SI_BATCH_NOT_FOUND: {
|
||||
httpStatus: 404,
|
||||
message_sv: 'Betalfilen kunde inte hittas.',
|
||||
message_en: 'Payment batch not found.',
|
||||
},
|
||||
SI_BATCH_INELIGIBLE_INVOICE: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'En eller flera fakturor kan inte ingå i betalfilen. Se detaljerna för orsak per faktura.',
|
||||
message_en:
|
||||
'One or more invoices cannot be included in the payment batch. See details for the per-invoice reason.',
|
||||
},
|
||||
SI_BATCH_INVALID_AMOUNT: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Betalbeloppet måste vara större än noll.',
|
||||
message_en: 'The payment amount must be greater than zero.',
|
||||
},
|
||||
SI_BATCH_AMOUNT_EXCEEDS_REMAINING: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Betalbeloppet är större än kvar att betala på fakturan.',
|
||||
message_en: "The payment amount exceeds the invoice's remaining amount.",
|
||||
},
|
||||
SI_BATCH_DUPLICATE_INVOICE: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'En eller flera fakturor ingår redan i en aktiv betalfil. Bekräfta att du vill skapa en ny betalning ändå.',
|
||||
message_en:
|
||||
'One or more invoices are already part of an active payment batch. Confirm to create another payment anyway.',
|
||||
remediation: {
|
||||
description:
|
||||
'Resend with confirm_already_batched: true to include the invoices anyway, or cancel the existing batch first via POST /api/supplier-invoices/payment-batches/{id}/cancel.',
|
||||
},
|
||||
},
|
||||
SI_BATCH_DEBTOR_INCOMPLETE: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Företagets bankuppgifter är ofullständiga. Fyll i IBAN (och BIC om det inte kan härledas) under Inställningar → Fakturering.',
|
||||
message_en:
|
||||
'The company bank details are incomplete. Enter the IBAN (and BIC if it cannot be derived) under Settings → Invoicing.',
|
||||
},
|
||||
SI_BATCH_CANCELLED: {
|
||||
httpStatus: 409,
|
||||
message_sv: 'Betalfilen är makulerad och kan inte laddas ner.',
|
||||
message_en: 'The payment batch is cancelled and cannot be downloaded.',
|
||||
},
|
||||
SI_BATCH_ALREADY_CANCELLED: {
|
||||
httpStatus: 409,
|
||||
message_sv: 'Betalfilen är redan makulerad.',
|
||||
message_en: 'The payment batch is already cancelled.',
|
||||
},
|
||||
SI_BATCH_CREATE_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Kunde inte skapa betalfilen.',
|
||||
message_en: 'Failed to create the payment batch.',
|
||||
},
|
||||
SI_DELETE_IN_PAYMENT_BATCH: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'Leverantörsfakturan ingår i en betalfil och kan inte tas bort: betalfilens rader är underlag för betalningsinstruktionen, även om filen makulerats.',
|
||||
message_en:
|
||||
'The supplier invoice is part of a payment batch and cannot be deleted: the batch rows document the payment instruction, even if the batch was cancelled.',
|
||||
},
|
||||
}
|
||||
|
||||
const SALARY: Record<string, StructuredErrorEntry> = {
|
||||
|
||||
Reference in New Issue
Block a user