From 576a34750a10104ebcd5af2210c8e83d7d0a2dd5 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Mon, 10 Aug 2026 20:00:42 +0200 Subject: [PATCH] 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 --- app/api/supplier-invoices/[id]/route.ts | 22 + .../payment-batches/[id]/cancel/route.ts | 47 ++ .../payment-batches/[id]/file/route.ts | 70 +++ .../payment-batches/[id]/route.ts | 37 ++ .../payment-batches/__tests__/route.test.ts | 461 ++++++++++++++++++ .../payment-batches/preview/route.ts | 33 ++ .../payment-batches/route.ts | 138 ++++++ lib/api/schemas.ts | 33 ++ lib/errors/structured-errors.ts | 62 +++ 9 files changed, 903 insertions(+) create mode 100644 app/api/supplier-invoices/payment-batches/[id]/cancel/route.ts create mode 100644 app/api/supplier-invoices/payment-batches/[id]/file/route.ts create mode 100644 app/api/supplier-invoices/payment-batches/[id]/route.ts create mode 100644 app/api/supplier-invoices/payment-batches/__tests__/route.test.ts create mode 100644 app/api/supplier-invoices/payment-batches/preview/route.ts create mode 100644 app/api/supplier-invoices/payment-batches/route.ts diff --git a/app/api/supplier-invoices/[id]/route.ts b/app/api/supplier-invoices/[id]/route.ts index d9012580..cb1e292b 100644 --- a/app/api/supplier-invoices/[id]/route.ts +++ b/app/api/supplier-invoices/[id]/route.ts @@ -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) diff --git a/app/api/supplier-invoices/payment-batches/[id]/cancel/route.ts b/app/api/supplier-invoices/payment-batches/[id]/cancel/route.ts new file mode 100644 index 00000000..24371374 --- /dev/null +++ b/app/api/supplier-invoices/payment-batches/[id]/cancel/route.ts @@ -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 }, +) diff --git a/app/api/supplier-invoices/payment-batches/[id]/file/route.ts b/app/api/supplier-invoices/payment-batches/[id]/file/route.ts new file mode 100644 index 00000000..c85930bb --- /dev/null +++ b/app/api/supplier-invoices/payment-batches/[id]/file/route.ts @@ -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 }, +) diff --git a/app/api/supplier-invoices/payment-batches/[id]/route.ts b/app/api/supplier-invoices/payment-batches/[id]/route.ts new file mode 100644 index 00000000..b1bb2f5f --- /dev/null +++ b/app/api/supplier-invoices/payment-batches/[id]/route.ts @@ -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 ?? [] } }) + }, +) diff --git a/app/api/supplier-invoices/payment-batches/__tests__/route.test.ts b/app/api/supplier-invoices/payment-batches/__tests__/route.test.ts new file mode 100644 index 00000000..18bd319c --- /dev/null +++ b/app/api/supplier-invoices/payment-batches/__tests__/route.test.ts @@ -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 = {}) { + 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 = {}) { + 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 = {}) { + 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('ACCOUNTED-5566778899-BB1111111') + expect(xml).toContain('9900') + + const stamp = findCall('supplier_payment_batches', 'update')?.[0] as Record + 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') + }) +}) diff --git a/app/api/supplier-invoices/payment-batches/preview/route.ts b/app/api/supplier-invoices/payment-batches/preview/route.ts new file mode 100644 index 00000000..683e02dc --- /dev/null +++ b/app/api/supplier-invoices/payment-batches/preview/route.ts @@ -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 }, +) diff --git a/app/api/supplier-invoices/payment-batches/route.ts b/app/api/supplier-invoices/payment-batches/route.ts new file mode 100644 index 00000000..861936f9 --- /dev/null +++ b/app/api/supplier-invoices/payment-batches/route.ts @@ -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() + const invoiceIdsByBatch = new Map() + 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) ?? [] } + : {}), + })), + }) + }, +) diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index e8d0f528..c7bec459 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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 // ============================================================ diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 44f35443..51f92782 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -2191,6 +2191,68 @@ const SUPPLIER_INVOICE_WAVE4: Record = { 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 = {