diff --git a/.gitignore b/.gitignore index b42005de..841fea8f 100644 --- a/.gitignore +++ b/.gitignore @@ -66,20 +66,6 @@ supabase/.temp/ # `npm run taxonomy:check` (core-build.yml) and the golden test pins against # the example document. /dev_docs/* -!/dev_docs/bokslut/ -/dev_docs/bokslut/* -!/dev_docs/bokslut/taxonomi/ -/dev_docs/bokslut/taxonomi/* -!/dev_docs/bokslut/taxonomi/taxonomi-paket-2024-09-12_rev20250312.zip -!/dev_docs/bokslut/taxonomi/dokumentation/ -/dev_docs/bokslut/taxonomi/dokumentation/* -!/dev_docs/bokslut/taxonomi/dokumentation/k2-ab-arsredovisning-elementlista-2024-09-12_rev20250312_sv.xlsx -!/dev_docs/bokslut/taxonomi/dokumentation/tuple-innehallsmodell-arsredovisning-k2-2024-09-12.xlsx -!/dev_docs/bokslut/exempel/ -/dev_docs/bokslut/exempel/* -!/dev_docs/bokslut/exempel/k2/ -/dev_docs/bokslut/exempel/k2/* -!/dev_docs/bokslut/exempel/k2/faststalld-arsredovisning-exempel-1-rev20240214.xhtml # Extension registry (auto-generated but defaults are committed) # Run `npm run setup:extensions` to regenerate after changing extensions.config.json diff --git a/app/api/documents/route.ts b/app/api/documents/route.ts index 57bc9087..c74136c7 100644 --- a/app/api/documents/route.ts +++ b/app/api/documents/route.ts @@ -71,11 +71,20 @@ export const POST = withRouteContext( details: { reason: message }, }) } + // Magic-byte validation rejections (validateDocumentMagicBytes) are a + // client problem, not a storage failure — surface as 400 with an + // accurate message instead of the misleading "kunde inte sparas". + if (/kunde inte verifieras|matchar inte den angivna filtypen/i.test(message)) { + opLog.warn('document upload rejected by content validation', { reason: message }) + return errorResponseFromCode('DOC_UPLOAD_INVALID_CONTENT', opLog, { + requestId, + details: { reason: message }, + }) + } + // Full error is logged above; the raw message can leak storage-layer + // internals, so the client only gets the generic code + requestId. opLog.error('document upload failed', err as Error) - return errorResponseFromCode('DOC_UPLOAD_STORAGE_FAILED', opLog, { - requestId, - details: { reason: message }, - }) + return errorResponseFromCode('DOC_UPLOAD_STORAGE_FAILED', opLog, { requestId }) } }, { requireWrite: true }, diff --git a/app/api/rot-rut/__tests__/routes.test.ts b/app/api/rot-rut/__tests__/routes.test.ts new file mode 100644 index 00000000..f18deef1 --- /dev/null +++ b/app/api/rot-rut/__tests__/routes.test.ts @@ -0,0 +1,496 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + createMockRouteParams, + parseJsonResponse, + createQueuedMockSupabase, + makeInvoice, +} from '@/tests/helpers' +import { encryptPersonnummer } from '@/lib/salary/personnummer' +import type { Invoice, InvoiceItem } from '@/types' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +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 }), +})) + +const mockUploadDocument = vi.fn() +vi.mock('@/lib/core/documents/document-service', () => ({ + uploadDocument: (...args: unknown[]) => mockUploadDocument(...args), +})) + +const mockCreatePayoutEntry = vi.fn() +vi.mock('@/lib/bookkeeping/rot-rut-entries', () => ({ + createRotRutPayoutEntry: (...args: unknown[]) => mockCreatePayoutEntry(...args), +})) + +import { GET as eligibleGET } from '../eligible/route' +import { POST as payoutFilePOST } from '../payout-file/route' +import { GET as requestsGET } from '../payout-requests/route' +import { PATCH as requestPATCH } from '../payout-requests/[id]/route' +import { POST as settlePOST } from '../payout-requests/[id]/settle/route' + +const INVOICE_ID = '11111111-1111-4111-8111-111111111111' +const REQUEST_ID = '22222222-2222-4222-8222-222222222222' +// Skatteverket official example personnummer (synthetic). +const PNR = '198406012388' + +const mockUser = { id: 'user-1', email: 'test@test.se' } + +function makeRotItem(overrides: Partial = {}): InvoiceItem { + return { + id: 'item-1', + invoice_id: INVOICE_ID, + sort_order: 0, + description: 'Snickeri', + quantity: 1, + unit: 'tim', + unit_price: 10000, + line_total: 10000, + vat_rate: 25, + vat_amount: 2500, + deduction_type: 'rot', + deduction_amount: 3000, + labor_hours: 25, + work_type: 'BYGG', + housing_designation: 'Stockholm Vasastan 1:23', + apartment_number: null, + brf_org_number: null, + created_at: '2026-06-01T00:00:00Z', + ...overrides, + } +} + +function makePaidRotInvoice(overrides: Partial = {}): Invoice { + return makeInvoice({ + id: INVOICE_ID, + status: 'paid', + paid_at: '2026-06-20T10:00:00Z', + deduction_total: 3000, + deduction_personnummer_encrypted: encryptPersonnummer(PNR), + items: [makeRotItem()], + ...overrides, + }) +} + +function makePayoutRequestRow(overrides: Record = {}) { + return { + id: REQUEST_ID, + company_id: 'company-1', + user_id: 'user-1', + deduction_type: 'rot', + name: 'ROT 2026-07-02', + status: 'generated', + requested_total: 3000, + decided_total: null, + file_name: 'rot_begaran_2026-07-02.xml', + file_document_id: null, + settlement_journal_entry_id: null, + submitted_at: null, + decided_at: null, + created_at: '2026-07-02T00:00:00Z', + updated_at: '2026-07-02T00:00:00Z', + ...overrides, + } +} + +beforeEach(() => { + vi.clearAllMocks() + reset() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + mockUploadDocument.mockResolvedValue({ id: 'doc-1' }) +}) + +describe('GET /api/rot-rut/eligible', () => { + it('returns 401 when not authenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + const response = await eligibleGET(createMockRequest('/api/rot-rut/eligible')) + expect(response.status).toBe(401) + }) + + it('splits invoices into eligible and blocked', async () => { + const good = makePaidRotInvoice() + const missingHours = makePaidRotInvoice({ + id: '33333333-3333-4333-8333-333333333333', + invoice_number: 'F-BAD', + items: [makeRotItem({ labor_hours: null })], + }) + enqueue({ data: [good, missingHours] }) + enqueue({ data: [] }) // no active request items + + const response = await eligibleGET( + createMockRequest('/api/rot-rut/eligible', { searchParams: { type: 'rot' } }), + ) + const { status, body } = await parseJsonResponse<{ + data: { eligible: Array<{ invoice_id: string; begart_belopp: number }>; blocked: Array<{ code: string }> } + }>(response) + + expect(status).toBe(200) + expect(body.data.eligible).toHaveLength(1) + expect(body.data.eligible[0].invoice_id).toBe(INVOICE_ID) + expect(body.data.eligible[0].begart_belopp).toBe(3000) + expect(body.data.blocked).toHaveLength(1) + expect(body.data.blocked[0].code).toBe('MISSING_HOURS') + }) + + it('hides invoices already in an active request', async () => { + enqueue({ data: [makePaidRotInvoice()] }) + enqueue({ data: [{ invoice_id: INVOICE_ID, request: { id: 'r', status: 'submitted', company_id: 'company-1' } }] }) + + const response = await eligibleGET(createMockRequest('/api/rot-rut/eligible')) + const { body } = await parseJsonResponse<{ + data: { eligible: unknown[]; blocked: unknown[] } + }>(response) + + expect(body.data.eligible).toHaveLength(0) + expect(body.data.blocked).toHaveLength(0) + }) + + it('returns 500 on database error', async () => { + enqueue({ data: null, error: { message: 'boom' } }) + const response = await eligibleGET(createMockRequest('/api/rot-rut/eligible')) + expect(response.status).toBe(500) + }) +}) + +describe('POST /api/rot-rut/payout-file', () => { + const validBody = { deduction_type: 'rot', invoice_ids: [INVOICE_ID] } + + it('returns 401 when not authenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + const response = await payoutFilePOST( + createMockRequest('/api/rot-rut/payout-file', { method: 'POST', body: validBody }), + ) + expect(response.status).toBe(401) + }) + + it('returns 400 on invalid body', async () => { + const response = await payoutFilePOST( + createMockRequest('/api/rot-rut/payout-file', { + method: 'POST', + body: { deduction_type: 'gront', invoice_ids: [] }, + }), + ) + expect(response.status).toBe(400) + }) + + it('generates the file, records the request and archives the document', async () => { + enqueue({ data: [makePaidRotInvoice()] }) // invoices fetch + enqueue({ data: makePayoutRequestRow() }) // request insert + enqueue({ data: null }) // items insert + enqueue({ data: null }) // file_document_id update + + const response = await payoutFilePOST( + createMockRequest('/api/rot-rut/payout-file', { method: 'POST', body: validBody }), + ) + const { status, body } = await parseJsonResponse<{ + data: { xml: string; file_name: string; arenden: unknown[]; request: { id: string } } + }>(response) + + expect(status).toBe(200) + expect(body.data.xml).toContain('') + expect(body.data.xml).toContain(`${PNR}`) + expect(body.data.arenden).toHaveLength(1) + expect(body.data.request.id).toBe(REQUEST_ID) + expect(mockUploadDocument).toHaveBeenCalledTimes(1) + }) + + it('rejects all-or-nothing when a selected invoice is blocked', async () => { + enqueue({ + data: [ + makePaidRotInvoice(), + makePaidRotInvoice({ + id: '33333333-3333-4333-8333-333333333333', + status: 'sent', + }), + ], + }) + + const response = await payoutFilePOST( + createMockRequest('/api/rot-rut/payout-file', { + method: 'POST', + body: { + deduction_type: 'rot', + invoice_ids: [INVOICE_ID, '33333333-3333-4333-8333-333333333333'], + }, + }), + ) + const { status, body } = await parseJsonResponse<{ + error: { code: string; details?: { blockers: Array<{ code: string }> } } + }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('ROT_RUT_INVOICES_BLOCKED') + }) + + it('returns 404 when an invoice id does not belong to the company', async () => { + enqueue({ data: [] }) + const response = await payoutFilePOST( + createMockRequest('/api/rot-rut/payout-file', { method: 'POST', body: validBody }), + ) + expect(response.status).toBe(404) + }) + + it('maps the double-request trigger to 409 and rolls back the header row', async () => { + enqueue({ data: [makePaidRotInvoice()] }) + enqueue({ data: makePayoutRequestRow() }) + enqueue({ data: null, error: { code: '23505', message: 'already included in an active rot/rut payout request' } }) + enqueue({ data: null }) // rollback delete + + const response = await payoutFilePOST( + createMockRequest('/api/rot-rut/payout-file', { method: 'POST', body: validBody }), + ) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(409) + expect(body.error.code).toBe('ROT_RUT_INVOICE_CONFLICT') + }) +}) + +describe('GET /api/rot-rut/payout-requests', () => { + it('lists requests', async () => { + enqueue({ data: [makePayoutRequestRow()] }) + const response = await requestsGET(createMockRequest('/api/rot-rut/payout-requests')) + const { status, body } = await parseJsonResponse<{ data: unknown[] }>(response) + expect(status).toBe(200) + expect(body.data).toHaveLength(1) + }) +}) + +describe('PATCH /api/rot-rut/payout-requests/[id]', () => { + const routeParams = createMockRouteParams({ id: REQUEST_ID }) + + it('returns 404 for an unknown request', async () => { + enqueue({ data: null }) + const response = await requestPATCH( + createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}`, { + method: 'PATCH', + body: { status: 'submitted' }, + }), + routeParams, + ) + expect(response.status).toBe(404) + }) + + it('rejects an invalid transition', async () => { + enqueue({ data: makePayoutRequestRow({ status: 'paid' }) }) + const response = await requestPATCH( + createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}`, { + method: 'PATCH', + body: { status: 'submitted' }, + }), + routeParams, + ) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + expect(status).toBe(400) + expect(body.error.code).toBe('ROT_RUT_INVALID_STATUS_TRANSITION') + }) + + it('requires decided_total for partially_paid', async () => { + enqueue({ data: makePayoutRequestRow({ status: 'submitted' }) }) + const response = await requestPATCH( + createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}`, { + method: 'PATCH', + body: { status: 'partially_paid' }, + }), + routeParams, + ) + expect(response.status).toBe(400) + }) + + it('marks a generated request as submitted', async () => { + enqueue({ data: makePayoutRequestRow() }) + enqueue({ data: makePayoutRequestRow({ status: 'submitted', submitted_at: '2026-07-02T12:00:00Z' }) }) + + const response = await requestPATCH( + createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}`, { + method: 'PATCH', + body: { status: 'submitted' }, + }), + routeParams, + ) + const { status, body } = await parseJsonResponse<{ data: { status: string } }>(response) + expect(status).toBe(200) + expect(body.data.status).toBe('submitted') + }) + + it('records a rejection with decided_total 0', async () => { + enqueue({ data: makePayoutRequestRow({ status: 'submitted' }) }) + enqueue({ data: makePayoutRequestRow({ status: 'rejected', decided_total: 0 }) }) + + const response = await requestPATCH( + createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}`, { + method: 'PATCH', + body: { status: 'rejected' }, + }), + routeParams, + ) + const { status, body } = await parseJsonResponse<{ data: { status: string } }>(response) + expect(status).toBe(200) + expect(body.data.status).toBe('rejected') + }) +}) + +describe('POST /api/rot-rut/payout-requests/[id]/settle', () => { + const routeParams = createMockRouteParams({ id: REQUEST_ID }) + const settleBody = { payment_date: '2026-07-10' } + + it('returns 404 for an unknown request', async () => { + enqueue({ data: null }) + const response = await settlePOST( + createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}/settle`, { + method: 'POST', + body: settleBody, + }), + routeParams, + ) + expect(response.status).toBe(404) + }) + + it('refuses an already settled request', async () => { + enqueue({ + data: makePayoutRequestRow({ + status: 'paid', + settlement_journal_entry_id: 'je-1', + }), + }) + const response = await settlePOST( + createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}/settle`, { + method: 'POST', + body: settleBody, + }), + routeParams, + ) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + expect(status).toBe(400) + expect(body.error.code).toBe('ROT_RUT_SETTLE_INVALID_STATE') + }) + + it('books the payout and completes the request as paid', async () => { + mockCreatePayoutEntry.mockResolvedValue({ id: 'je-1' }) + enqueue({ data: makePayoutRequestRow({ status: 'submitted' }) }) + enqueue({ + data: makePayoutRequestRow({ + status: 'paid', + settlement_journal_entry_id: 'je-1', + decided_total: 3000, + }), + }) + enqueue({ data: [{ id: 'item-1', requested_amount: 3000 }] }) + enqueue({ data: null }) // item decided_amount update + + const response = await settlePOST( + createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}/settle`, { + method: 'POST', + body: settleBody, + }), + routeParams, + ) + const { status, body } = await parseJsonResponse<{ + data: { journal_entry_id: string; request: { status: string } } + }>(response) + + expect(status).toBe(200) + expect(body.data.journal_entry_id).toBe('je-1') + expect(body.data.request.status).toBe('paid') + expect(mockCreatePayoutEntry).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + expect.objectContaining({ amount: 3000, paymentDate: '2026-07-10' }), + ) + }) + + it('forwards bank_account to the engine and defaults it to undefined', async () => { + mockCreatePayoutEntry.mockResolvedValue({ id: 'je-3' }) + enqueue({ data: makePayoutRequestRow({ status: 'submitted' }) }) + enqueue({ data: makePayoutRequestRow({ status: 'paid', settlement_journal_entry_id: 'je-3' }) }) + enqueue({ data: [] }) + + const response = await settlePOST( + createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}/settle`, { + method: 'POST', + body: { payment_date: '2026-07-10', bank_account: '1920' }, + }), + routeParams, + ) + expect(response.status).toBe(200) + expect(mockCreatePayoutEntry).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + expect.objectContaining({ bankAccount: '1920' }), + ) + }) + + it('rejects a non-19xx bank_account', async () => { + const response = await settlePOST( + createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}/settle`, { + method: 'POST', + body: { payment_date: '2026-07-10', bank_account: '3001' }, + }), + routeParams, + ) + expect(response.status).toBe(400) + }) + + it('books a partial payout as partially_paid once the beslut is recorded', async () => { + mockCreatePayoutEntry.mockResolvedValue({ id: 'je-2' }) + enqueue({ data: makePayoutRequestRow({ status: 'submitted', decided_total: 2000 }) }) + enqueue({ + data: makePayoutRequestRow({ status: 'partially_paid', decided_total: 2000 }), + }) + + const response = await settlePOST( + createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}/settle`, { + method: 'POST', + body: { payment_date: '2026-07-10', amount: 2000 }, + }), + routeParams, + ) + const { status, body } = await parseJsonResponse<{ data: { request: { status: string } } }>(response) + + expect(status).toBe(200) + expect(body.data.request.status).toBe('partially_paid') + }) + + it('refuses a partial settlement before the beslut is recorded', async () => { + enqueue({ data: makePayoutRequestRow({ status: 'submitted', decided_total: null }) }) + + const response = await settlePOST( + createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}/settle`, { + method: 'POST', + body: { payment_date: '2026-07-10', amount: 2000 }, + }), + routeParams, + ) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).not.toBe(200) + expect(body.error.code).toBe('ROT_RUT_SETTLE_INVALID_STATE') + expect(mockCreatePayoutEntry).not.toHaveBeenCalled() + }) + + it('returns 500 and does not update the request when the engine fails', async () => { + mockCreatePayoutEntry.mockRejectedValue(new Error('period locked')) + enqueue({ data: makePayoutRequestRow({ status: 'submitted' }) }) + + const response = await settlePOST( + createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}/settle`, { + method: 'POST', + body: settleBody, + }), + routeParams, + ) + expect(response.status).toBe(500) + }) +}) diff --git a/app/api/rot-rut/eligible/route.ts b/app/api/rot-rut/eligible/route.ts new file mode 100644 index 00000000..41244452 --- /dev/null +++ b/app/api/rot-rut/eligible/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse } from '@/lib/errors/get-structured-error' +import { listRotRutCandidates } from '@/lib/invoices/rot-rut-service' + +/** + * GET /api/rot-rut/eligible?type=rot|rut + * + * Lists paid invoices carrying a ROT/RUT claim that are NOT yet part of an + * active begäran om utbetalning, split into: + * - eligible: ready for file generation (with the amounts the file will use) + * - blocked: excluded, with the exact blocker (same evaluation as the + * generator — what this endpoint approves, the file accepts) + */ +export const GET = withRouteContext('rot_rut.eligible', async (request, ctx) => { + const { supabase, companyId, log, requestId } = ctx + + const { searchParams } = new URL(request.url) + const typeParam = searchParams.get('type') + const type = typeParam === 'rut' ? 'rut' : 'rot' + + const result = await listRotRutCandidates(supabase, companyId!, type) + if (!result.ok) { + log.error('failed to list rot/rut candidates', result.dbError as Error) + return errorResponse(result.dbError, log, { requestId }) + } + + return NextResponse.json({ + data: { type, eligible: result.eligible, blocked: result.blocked }, + }) +}) diff --git a/app/api/rot-rut/payout-file/route.ts b/app/api/rot-rut/payout-file/route.ts new file mode 100644 index 00000000..eda00abd --- /dev/null +++ b/app/api/rot-rut/payout-file/route.ts @@ -0,0 +1,93 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateBody } from '@/lib/api/validate' +import { RotRutPayoutFileSchema } from '@/lib/api/schemas' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { createRotRutPayoutRequest } from '@/lib/invoices/rot-rut-service' +import { uploadDocument } from '@/lib/core/documents/document-service' + +/** + * POST /api/rot-rut/payout-file + * + * Generates the begäran-om-utbetalning XML (Skatteverket husavdrag, schema + * V6) for the selected invoices, records a rot_rut_payout_requests row (one + * active begäran per invoice — DB-enforced), archives the file as + * räkenskapsinformation, and returns the XML for download. + * + * All-or-nothing: if any selected invoice fails eligibility the request is + * rejected with per-invoice blockers — a silently thinner file would be a + * guess about the user's intent. + * + * DELIBERATE: the XML (which embeds buyers' personnummer, as Skatteverkets + * schema requires) is returned inline. The file only exists to be saved and + * uploaded manually on skatteverket.se — there is no UI download surface for + * this headless flow, and a document-reference indirection would dead-end the + * user whenever the (best-effort) archive failed. Transport is TLS, + * authenticated, MFA-gated and write-role-gated via withRouteContext. + */ +export const POST = withRouteContext( + 'rot_rut.payout_file', + async (request, ctx) => { + const { user, supabase, companyId, log, requestId } = ctx + + const validation = await validateBody(request, RotRutPayoutFileSchema) + if (!validation.success) return validation.response + const input = validation.data + + const result = await createRotRutPayoutRequest(supabase, companyId!, user.id, { + type: input.deduction_type, + invoiceIds: input.invoice_ids, + name: input.name, + }) + + if (!result.ok) { + return errorResponseFromCode(result.code, log, { + requestId, + details: { + ...(result.blockers ? { blockers: result.blockers } : {}), + ...(result.missingInvoiceIds ? { missing_invoice_ids: result.missingInvoiceIds } : {}), + }, + }) + } + + // Archive the XML as räkenskapsinformation (7-year retention via the + // document WORM chain). Best-effort: the user gets the file either way + // and can re-generate; a failed archive must not orphan the begäran. + let fileDocumentId: string | null = null + try { + const buffer = new TextEncoder().encode(result.file.xml!).buffer as ArrayBuffer + const doc = await uploadDocument( + supabase, + user.id, + companyId!, + { name: result.file.file_name, buffer }, + { upload_source: 'system' }, + ) + fileDocumentId = doc.id + await supabase + .from('rot_rut_payout_requests') + .update({ file_document_id: doc.id }) + .eq('id', result.request.id as string) + } catch (docError) { + log.error('failed to archive rot/rut payout file document', docError as Error) + } + + log.info('rot/rut payout file generated', { + requestId: result.request.id, + type: input.deduction_type, + arenden: result.file.arenden.length, + requestedTotal: result.file.requested_total, + }) + + return NextResponse.json({ + data: { + request: { ...result.request, file_document_id: fileDocumentId }, + xml: result.file.xml, + file_name: result.file.file_name, + arenden: result.file.arenden, + warnings: result.file.warnings, + }, + }) + }, + { requireWrite: true }, +) diff --git a/app/api/rot-rut/payout-requests/[id]/route.ts b/app/api/rot-rut/payout-requests/[id]/route.ts new file mode 100644 index 00000000..b17a3008 --- /dev/null +++ b/app/api/rot-rut/payout-requests/[id]/route.ts @@ -0,0 +1,139 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateBody } from '@/lib/api/validate' +import { RotRutRequestPatchSchema } from '@/lib/api/schemas' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' +import type { RotRutPayoutRequestStatus } from '@/types' + +/** + * Forward-only lifecycle. Reactivation of cancelled/rejected begäran is + * deliberately impossible from the API (and double-guarded by the DB + * trigger enforce_rot_rut_request_reactivation) — retry after avslag means + * generating a NEW file, mirroring how Skatteverkets e-tjänst works. + */ +const ALLOWED_TRANSITIONS: Record = { + generated: ['submitted', 'cancelled'], + submitted: ['paid', 'partially_paid', 'rejected', 'cancelled'], + paid: [], + partially_paid: [], + rejected: [], + cancelled: [], +} + +/** + * PATCH /api/rot-rut/payout-requests/[id] + * + * Advance the begäran lifecycle: mark the file as uploaded (submitted), or + * record Skatteverkets beslut (paid / partially_paid / rejected + + * decided_total). Booking the actual payout is POST [id]/settle. + */ +export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>( + 'rot_rut.requests.patch', + async (request, ctx, { params }) => { + const { user, supabase, companyId, log, requestId } = ctx + const { id } = await params + + const validation = await validateBody(request, RotRutRequestPatchSchema) + if (!validation.success) return validation.response + const input = validation.data + + const { data: payoutRequest, error: fetchError } = await supabase + .from('rot_rut_payout_requests') + .select('*') + .eq('company_id', companyId!) + .eq('id', id) + .maybeSingle() + + if (fetchError) { + log.error('failed to fetch rot/rut payout request', fetchError) + return errorResponse(fetchError, log, { requestId }) + } + if (!payoutRequest) { + return errorResponseFromCode('ROT_RUT_REQUEST_NOT_FOUND', log, { requestId }) + } + + const from = payoutRequest.status as RotRutPayoutRequestStatus + if (!ALLOWED_TRANSITIONS[from]?.includes(input.status)) { + return errorResponseFromCode('ROT_RUT_INVALID_STATUS_TRANSITION', log, { + requestId, + details: { from, to: input.status }, + }) + } + // Partial approval without the approved amount is meaningless. + if (input.status === 'partially_paid' && input.decided_total === undefined) { + return errorResponseFromCode('ROT_RUT_INVALID_STATUS_TRANSITION', log, { + requestId, + details: { from, to: input.status, reason: 'decided_total krävs för delvis beviljad' }, + }) + } + + const now = new Date().toISOString() + const update: Record = { status: input.status } + if (input.status === 'submitted') { + update.submitted_at = now + } + if (input.status === 'paid' || input.status === 'partially_paid' || input.status === 'rejected') { + update.decided_at = now + update.decided_total = + input.decided_total ?? + (input.status === 'paid' ? payoutRequest.requested_total : 0) + } + + const { data: updated, error: updateError } = await supabase + .from('rot_rut_payout_requests') + .update(update) + .eq('company_id', companyId!) + .eq('id', id) + .select( + 'id, name, deduction_type, status, requested_total, decided_total, submitted_at, decided_at, settlement_journal_entry_id', + ) + .single() + + if (updateError) { + log.error('failed to update rot/rut payout request', updateError) + return errorResponse(updateError, log, { requestId }) + } + + // Status transitions record Skatteverkets beslut — the audit trail must + // show who recorded them. + log.info('rot/rut payout request status changed', { + userId: user.id, + payoutRequestId: id, + from, + to: input.status, + decidedTotal: update.decided_total ?? null, + }) + + // Full approval: mirror the per-invoice godkänt belopp onto the items. + // Partial approval leaves item amounts null — the split is only known + // from Skatteverkets beslut, never guessed. + if (input.status === 'paid' && input.decided_total === undefined) { + const { data: items, error: itemsFetchError } = await supabase + .from('rot_rut_payout_request_items') + .select('id, requested_amount') + .eq('request_id', id) + if (itemsFetchError) { + log.warn('failed to fetch items for decided_amount mirror', { + requestId: id, + message: itemsFetchError.message, + }) + } else { + for (const item of items ?? []) { + const { error: mirrorError } = await supabase + .from('rot_rut_payout_request_items') + .update({ decided_amount: item.requested_amount }) + .eq('id', item.id) + if (mirrorError) { + log.warn('failed to mirror decided_amount onto item', { + itemId: item.id, + message: mirrorError.message, + }) + } + } + } + } + + return NextResponse.json({ data: updated }) + }, + { requireWrite: true }, +) diff --git a/app/api/rot-rut/payout-requests/[id]/settle/route.ts b/app/api/rot-rut/payout-requests/[id]/settle/route.ts new file mode 100644 index 00000000..e77ba462 --- /dev/null +++ b/app/api/rot-rut/payout-requests/[id]/settle/route.ts @@ -0,0 +1,162 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateBody } from '@/lib/api/validate' +import { RotRutSettleSchema } from '@/lib/api/schemas' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { createRotRutPayoutEntry } from '@/lib/bookkeeping/rot-rut-entries' + +/** + * POST /api/rot-rut/payout-requests/[id]/settle + * + * Books Skatteverkets utbetalning for a begäran: + * + * Debit 19xx bank account (default 1930) [amount] + * Credit 1513 Skattereduktion rot/rut [amount] + * + * The journal entry IS the accounting record here, so engine failure blocks + * the operation (see .claude/skills/erp-api-route — payment entries block). + * amount defaults to decided_total, falling back to requested_total. If the + * amount equals requested_total the request completes as 'paid'; anything + * lower records 'partially_paid' with decided_total = amount. + */ +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'rot_rut.requests.settle', + async (request, ctx, { params }) => { + const { user, supabase, companyId, log, requestId } = ctx + const { id } = await params + + const validation = await validateBody(request, RotRutSettleSchema) + if (!validation.success) return validation.response + const input = validation.data + + const { data: payoutRequest, error: fetchError } = await supabase + .from('rot_rut_payout_requests') + .select('*') + .eq('company_id', companyId!) + .eq('id', id) + .maybeSingle() + + if (fetchError) { + log.error('failed to fetch rot/rut payout request', fetchError) + return errorResponse(fetchError, log, { requestId }) + } + if (!payoutRequest) { + return errorResponseFromCode('ROT_RUT_REQUEST_NOT_FOUND', log, { requestId }) + } + + const settleable = + !payoutRequest.settlement_journal_entry_id && + !['cancelled', 'rejected'].includes(payoutRequest.status) + if (!settleable) { + return errorResponseFromCode('ROT_RUT_SETTLE_INVALID_STATE', log, { + requestId, + details: { + status: payoutRequest.status, + already_settled: !!payoutRequest.settlement_journal_entry_id, + }, + }) + } + + const amount = + input.amount ?? Number(payoutRequest.decided_total ?? payoutRequest.requested_total) + + // A partial settlement must follow a recorded beslut: without this guard a + // settle with amount < requested_total on an undecided request would flip + // it to partially_paid while bypassing the PATCH lifecycle rule that + // partially_paid requires decided_total — the beslut would never be + // recorded and later PATCH calls would be blocked by ALLOWED_TRANSITIONS. + if (amount < Number(payoutRequest.requested_total) && payoutRequest.decided_total == null) { + return errorResponseFromCode('ROT_RUT_SETTLE_INVALID_STATE', log, { + requestId, + details: { + status: payoutRequest.status, + reason: + 'Delutbetalning kräver att Skatteverkets beslut registreras först (decided_total via PATCH).', + }, + }) + } + + // The voucher is the accounting record — engine failure must block. + let journalEntryId: string + try { + const entry = await createRotRutPayoutEntry(supabase, companyId!, user.id, { + requestId: payoutRequest.id, + requestName: payoutRequest.name, + deductionType: payoutRequest.deduction_type, + paymentDate: input.payment_date, + amount, + bankAccount: input.bank_account, + }) + journalEntryId = entry.id + } catch (engineError) { + log.error('failed to book rot/rut payout entry', engineError as Error) + return errorResponse(engineError, log, { requestId }) + } + + const fullyPaid = amount >= Number(payoutRequest.requested_total) + const update: Record = { + settlement_journal_entry_id: journalEntryId, + status: fullyPaid ? 'paid' : 'partially_paid', + decided_total: payoutRequest.decided_total ?? amount, + } + if (!payoutRequest.decided_at) { + update.decided_at = new Date().toISOString() + } + + const { data: updated, error: updateError } = await supabase + .from('rot_rut_payout_requests') + .update(update) + .eq('company_id', companyId!) + .eq('id', id) + .select( + 'id, name, deduction_type, status, requested_total, decided_total, decided_at, settlement_journal_entry_id', + ) + .single() + + if (updateError) { + // The voucher exists (immutable per BFL) but the request row didn't + // absorb the link — surface loudly, do NOT try to unbook. + log.error('rot/rut payout entry booked but request update failed', updateError, { + journalEntryId, + payoutRequestId: id, + }) + return errorResponse(updateError, log, { requestId }) + } + + if (fullyPaid) { + const { data: items, error: itemsFetchError } = await supabase + .from('rot_rut_payout_request_items') + .select('id, requested_amount') + .eq('request_id', id) + if (itemsFetchError) { + log.warn('failed to fetch items for decided_amount mirror', { + payoutRequestId: id, + message: itemsFetchError.message, + }) + } + for (const item of items ?? []) { + const { error: mirrorError } = await supabase + .from('rot_rut_payout_request_items') + .update({ decided_amount: item.requested_amount }) + .eq('id', item.id) + if (mirrorError) { + log.warn('failed to mirror decided_amount onto item', { + itemId: item.id, + message: mirrorError.message, + }) + } + } + } + + log.info('rot/rut payout settled', { + userId: user.id, + payoutRequestId: id, + journalEntryId, + amount, + fullyPaid, + }) + + return NextResponse.json({ data: { request: updated, journal_entry_id: journalEntryId } }) + }, + { requireWrite: true }, +) diff --git a/app/api/rot-rut/payout-requests/route.ts b/app/api/rot-rut/payout-requests/route.ts new file mode 100644 index 00000000..cb71c2c4 --- /dev/null +++ b/app/api/rot-rut/payout-requests/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse } from '@/lib/errors/get-structured-error' + +/** + * GET /api/rot-rut/payout-requests + * + * Request history (all statuses), newest first, with per-invoice items. + */ +export const GET = withRouteContext('rot_rut.requests.list', async (request, ctx) => { + const { supabase, companyId, log, requestId } = ctx + + const { searchParams } = new URL(request.url) + const typeFilter = searchParams.get('type') + + // Explicit projections — the history list needs identifiers and amounts, + // not every column (and never customer ids through the invoice join). + let query = supabase + .from('rot_rut_payout_requests') + .select( + 'id, name, deduction_type, status, requested_total, decided_total, file_name, file_document_id, ' + + 'created_at, submitted_at, decided_at, settlement_journal_entry_id, ' + + 'items:rot_rut_payout_request_items(id, invoice_id, requested_amount, decided_amount, ' + + 'invoice:invoices(id, invoice_number))', + ) + .eq('company_id', companyId!) + .order('created_at', { ascending: false }) + .limit(100) + + if (typeFilter === 'rot' || typeFilter === 'rut') { + query = query.eq('deduction_type', typeFilter) + } + + const { data, error } = await query + if (error) { + log.error('failed to list rot/rut payout requests', error) + return errorResponse(error, log, { requestId }) + } + + return NextResponse.json({ data }) +}) diff --git a/app/api/v1/companies/[companyId]/documents/route.ts b/app/api/v1/companies/[companyId]/documents/route.ts index 4b3f2236..a284fd37 100644 --- a/app/api/v1/companies/[companyId]/documents/route.ts +++ b/app/api/v1/companies/[companyId]/documents/route.ts @@ -289,10 +289,22 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( { requestId: ctx.requestId }, ) } catch (err) { + const message = err instanceof Error ? err.message : 'unknown' + // Magic-byte validation rejections (validateDocumentMagicBytes) are a + // client problem, not a storage failure — surface as 400 with an + // accurate message instead of the misleading "kunde inte sparas". + if (/kunde inte verifieras|matchar inte den angivna filtypen/i.test(message)) { + opLog.warn('document upload rejected by content validation', { reason: message }) + return v1ErrorResponseFromCode('DOC_UPLOAD_INVALID_CONTENT', opLog, { + requestId: ctx.requestId, + details: { reason: message }, + }) + } + // Full error is logged above; the raw message can leak storage-layer + // internals, so the client only gets the generic code + requestId. opLog.error('document upload failed', err as Error) return v1ErrorResponseFromCode('DOC_UPLOAD_STORAGE_FAILED', opLog, { requestId: ctx.requestId, - details: { reason: err instanceof Error ? err.message : 'unknown' }, }) } }, diff --git a/components/bookkeeping/AccountCombobox.tsx b/components/bookkeeping/AccountCombobox.tsx index e26841f3..422ee7ac 100644 --- a/components/bookkeeping/AccountCombobox.tsx +++ b/components/bookkeeping/AccountCombobox.tsx @@ -49,6 +49,11 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o const containerRef = useRef(null) const internalInputRef = useRef(null) const listRef = useRef(null) + // Whether the user has typed or arrow-navigated since the field was focused. + // Enter only selects the highlighted item after an actual interaction — a + // bare Enter on a freshly-focused field must not grab the first account in + // the list (it either re-commits the current value or bubbles to the form). + const hasInteractedRef = useRef(false) // Attach the internal ref (used for focus bookkeeping) and forward the element // to any external callback ref the parent passed. @@ -140,8 +145,14 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o const handleKeyDown = (e: React.KeyboardEvent) => { if (!isOpen) { if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + hasInteractedRef.current = true setIsOpen(true) e.preventDefault() + } else if (e.key === 'Enter' && /^\d{4}$/.test(search)) { + // Dropdown closed but a full account number sits in the field — treat + // Enter as a re-commit so focus advances to the amount field. + e.preventDefault() + onCommit?.(search) } return } @@ -149,16 +160,27 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o switch (e.key) { case 'ArrowDown': e.preventDefault() + hasInteractedRef.current = true setHighlightedIndex((prev) => Math.min(prev + 1, flatList.length - 1)) break case 'ArrowUp': e.preventDefault() + hasInteractedRef.current = true setHighlightedIndex((prev) => Math.max(prev - 1, 0)) break case 'Enter': - e.preventDefault() - if (flatList[highlightedIndex]) { + if (hasInteractedRef.current && flatList[highlightedIndex]) { + e.preventDefault() selectAccount(flatList[highlightedIndex].account_number) + } else if (/^\d{4}$/.test(search)) { + // Committed number, no new interaction — advance without re-selecting. + e.preventDefault() + setIsOpen(false) + onCommit?.(search) + } else { + // Nothing actively chosen — close the list and let the event bubble + // so the form-level Enter (open review when balanced) can take over. + setIsOpen(false) } break case 'Escape': @@ -170,6 +192,7 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o const handleInputChange = (e: React.ChangeEvent) => { const newValue = e.target.value + hasInteractedRef.current = true setSearch(newValue) // Emit any 4-digit numeric value to the parent. Unknown BAS numbers are // accepted optimistically — the submit-time ActivateAccountsDialog lets @@ -193,6 +216,7 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o } const handleFocus = () => { + hasInteractedRef.current = false setIsOpen(true) } diff --git a/components/bookkeeping/JournalEntryForm.tsx b/components/bookkeeping/JournalEntryForm.tsx index c0cbb377..a2f376d8 100644 --- a/components/bookkeeping/JournalEntryForm.tsx +++ b/components/bookkeeping/JournalEntryForm.tsx @@ -165,11 +165,16 @@ export default function JournalEntryForm({ // user typed in the combobox so we can prefill the dialog. const [creatingAccountForLine, setCreatingAccountForLine] = useState(null) const [createAccountPrefill, setCreateAccountPrefill] = useState('') - // Per-row refs to the debit inputs so we can auto-advance focus there once an - // account is committed on a row. Two layouts render simultaneously (mobile - // cards + desktop table); we focus whichever one is actually visible. + // Per-row refs to the account/debit/credit inputs so the keyboard flow can + // advance focus with Enter: konto → debet → kredit → nästa rads konto. Two + // layouts render simultaneously (mobile cards + desktop table); we focus + // whichever one is actually visible. + const desktopAccountRefs = useRef<(HTMLInputElement | null)[]>([]) + const mobileAccountRefs = useRef<(HTMLInputElement | null)[]>([]) const desktopDebitRefs = useRef<(HTMLInputElement | null)[]>([]) const mobileDebitRefs = useRef<(HTMLInputElement | null)[]>([]) + const desktopCreditRefs = useRef<(HTMLInputElement | null)[]>([]) + const mobileCreditRefs = useRef<(HTMLInputElement | null)[]>([]) // Confirm button in the inline (bare) review, focused on open so Enter posts. const bareConfirmRef = useRef(null) @@ -512,18 +517,37 @@ export default function JournalEntryForm({ updateLine(index, side === 'debit' ? 'debit_amount' : 'credit_amount', fill.toFixed(2)) } - // Move focus to a row's debit input. Deferred a frame so it runs after any + // Move focus to a row's input. Deferred a frame so it runs after any // re-render (e.g. the auto-appended trailing row). offsetParent is null for // display:none elements, so this picks whichever layout is currently visible. - const focusDebit = useCallback((index: number) => { - requestAnimationFrame(() => { - const d = desktopDebitRefs.current[index] - const m = mobileDebitRefs.current[index] - const target = d && d.offsetParent !== null ? d : m && m.offsetParent !== null ? m : (d ?? m) - target?.focus() - target?.select?.() - }) - }, []) + const focusRowInput = useCallback( + ( + desktop: React.RefObject<(HTMLInputElement | null)[]>, + mobile: React.RefObject<(HTMLInputElement | null)[]>, + index: number + ) => { + requestAnimationFrame(() => { + const d = desktop.current?.[index] + const m = mobile.current?.[index] + const target = d && d.offsetParent !== null ? d : m && m.offsetParent !== null ? m : (d ?? m) + target?.focus() + target?.select?.() + }) + }, + [] + ) + const focusAccount = useCallback( + (index: number) => focusRowInput(desktopAccountRefs, mobileAccountRefs, index), + [focusRowInput] + ) + const focusDebit = useCallback( + (index: number) => focusRowInput(desktopDebitRefs, mobileDebitRefs, index), + [focusRowInput] + ) + const focusCredit = useCallback( + (index: number) => focusRowInput(desktopCreditRefs, mobileCreditRefs, index), + [focusRowInput] + ) // Keep exactly one trailing blank row so the user never has to click "Lägg // till rad": once the last row is started (account or amount), append a fresh @@ -676,6 +700,44 @@ export default function JournalEntryForm({ if (canSubmitReview()) handleReview() } + // Enter-to-advance inside the konteringsrader: konto → debet → kredit → + // nästa rads konto. Navigation only fires while the entry is NOT + // submittable — once the voucher balances, Enter falls through to the + // form-level handler above and opens the review instead, so a single Enter + // never both moves focus and submits. + const handleAmountKeyDown = + (index: number, side: 'debit' | 'credit') => + (e: React.KeyboardEvent) => { + if (e.key !== 'Enter' || canSubmitReview()) return + e.preventDefault() + // An amount on this side finishes the row (debit clears credit and vice + // versa) → jump to the next row's account. An empty debit means the row + // books on the credit side → hop across first. + if (side === 'debit' && !(parseFloat(lines[index].debit_amount) > 0)) { + focusCredit(index) + } else { + focusAccount(index + 1) + } + } + + // Enter in a radbeskrivning continues to that row's amount. + const handleLineDescKeyDown = + (index: number) => (e: React.KeyboardEvent) => { + if (e.key !== 'Enter' || canSubmitReview()) return + e.preventDefault() + focusDebit(index) + } + + // Enter in the verifikationstext drops into the first row still missing an + // account, so the top-to-bottom keyboard flow never needs the mouse. + const handleHeaderDescKeyDown = (e: React.KeyboardEvent) => { + if (e.key !== 'Enter' || canSubmitReview()) return + const idx = lines.findIndex((l) => !l.account_number) + if (idx === -1) return + e.preventDefault() + focusAccount(idx) + } + // Inner submit: builds payload, POSTs, throws a structured error on failure // (so the activation hook can intercept ACCOUNTS_NOT_IN_CHART). const postJournalEntry = useCallback(async () => { @@ -929,7 +991,17 @@ export default function JournalEntryForm({ // summary instead of stacking a second dialog over the form dialog. The // no-underlag caveat folds in here so there's a single confirm step. const reviewPanel = ( -
+
{ + if (e.key === 'Escape' && !isSubmitting) { + e.stopPropagation() + setShowReview(false) + } + }} + >
+
+
+ ) : ( +
+
+
+ {domain.domain} + + {STATUS_BADGE[domain.status].label} + +
+
+ + +
+
+ + {domain.status === 'verified' ? ( +
+ +
+

+ Klart — ge dina leverantörer{' '} + faktura@{domain.domain} +

+

+ Alla adresser på domänen fungerar; allt landar i dokumentinkorgen. + {domain.verified_at ? ` Verifierad ${formatDateLong(domain.verified_at)}.` : ''} +

+
+
+ ) : ( +
+

+ Lägg till posterna nedan hos din domänleverantör (Loopia, one.com, + Cloudflare …) och klicka sedan på Kontrollera igen. Ändringar kan ta upp + till någon timme att slå igenom. +

+ {records.length > 0 ? ( +
+ + + + + + + + + + + {records.map((r, i) => ( + + + + + + + + ))} + +
TypNamnVärdePrio +
{r.type}{r.name}{r.value} + {r.priority ?? '—'} + + +
+
+ ) : ( +

+ Inga DNS-poster tillgängliga — klicka på Kontrollera igen. +

+ )} +
+ )} +
+ )} + + + ) +} diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx index df37ed38..ae33a894 100644 --- a/components/extensions/general/InvoiceInboxWorkspace.tsx +++ b/components/extensions/general/InvoiceInboxWorkspace.tsx @@ -44,6 +44,8 @@ import type { InvoiceExtractionResult } from '@/types' import BookDirectlyDialog from '@/components/extensions/general/BookDirectlyDialog' import NewSupplierInvoiceDialog from '@/components/supplier-invoices/NewSupplierInvoiceDialog' import BulkBookInboxDialog from '@/components/extensions/general/BulkBookInboxDialog' +// InboxCustomDomainDialog (egen domän) is built but gated off — see +// INBOX_CUSTOM_DOMAINS_ENABLED in extensions/general/invoice-inbox/index.ts. import TransactionMatchPicker from '@/components/inbox/TransactionMatchPicker' import { useAgentSheet } from '@/components/agent/AgentSheetProvider' diff --git a/components/invoices/InvoiceEditor.tsx b/components/invoices/InvoiceEditor.tsx index 20c50ce7..3c544879 100644 --- a/components/invoices/InvoiceEditor.tsx +++ b/components/invoices/InvoiceEditor.tsx @@ -138,6 +138,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat work_type: z.string().nullable().optional(), housing_designation: z.string().nullable().optional(), apartment_number: z.string().nullable().optional(), + brf_org_number: z.string().nullable().optional(), // Periodisering (förutbetald intäkt). Active when balance account is // non-null; both period dates are then required (refine below). accrual_period_start: z.string().nullable().optional(), @@ -199,7 +200,10 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat self_billing_agreement_ref: z.string().optional(), received_date: z.string().optional(), // Invoice-level ROT/RUT claim info. Personnummer is plaintext on - // the wire; the API encrypts it before storage. + // the wire; the API encrypts it before storage. The API additionally + // accepts the bostadsrätt pair (deduction_apartment_number + + // deduction_brf_org_number) — no editor UI for it yet, rot i + // bostadsrätt data enters via API/MCP until the payout-file UI ships. deduction_personnummer: z.string().optional(), deduction_housing_designation: z.string().optional(), items: z.array(itemSchema).min(1, t('validation_min_one_row')), @@ -262,6 +266,10 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat // draft. Starts true for create (always derive), false for edit (skip once). const didInitialCustomerSync = useRef(!isEditMode) + // Edit mode: the claim card's property fields are restored from the first + // rot line (they're stamped onto every rot line server-side at save time). + const initialRotLine = initial?.items?.find((i) => i.deduction_type === 'rot') ?? null + const { register, control, @@ -291,7 +299,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat self_billing_agreement_ref: '', received_date: '', deduction_personnummer: '', - deduction_housing_designation: '', + deduction_housing_designation: initialRotLine?.housing_designation ?? '', items: (initial.items ?? []).map((item) => ({ line_type: (item.line_type ?? 'product') as 'product' | 'text', description: item.description, @@ -306,6 +314,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat work_type: item.work_type ?? null, housing_designation: item.housing_designation ?? null, apartment_number: item.apartment_number ?? null, + brf_org_number: item.brf_org_number ?? null, accrual_period_start: item.accrual_period_start ?? null, accrual_period_end: item.accrual_period_end ?? null, accrual_balance_account: item.accrual_balance_account ?? null, @@ -334,6 +343,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat work_type: null, housing_designation: null, apartment_number: null, + brf_org_number: null, accrual_period_start: null, accrual_period_end: null, accrual_balance_account: null, @@ -927,6 +937,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat work_type: _wt, housing_designation: _hd, apartment_number: _an, + brf_org_number: _bn, ...rest } = item return rest @@ -1003,6 +1014,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat work_type: _wt, housing_designation: _hd, apartment_number: _an, + brf_org_number: _bn, ...rest } = item return rest @@ -1061,6 +1073,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat work_type: _wt, housing_designation: _hd, apartment_number: _an, + brf_org_number: _bn, ...rest } = item return rest @@ -1816,6 +1829,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat work_type: null, housing_designation: null, apartment_number: null, + brf_org_number: null, accrual_period_start: null, accrual_period_end: null, accrual_balance_account: null, @@ -1852,6 +1866,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat work_type: null, housing_designation: null, apartment_number: null, + brf_org_number: null, accrual_period_start: null, accrual_period_end: null, accrual_balance_account: null, diff --git a/components/settings/InvoiceEmailTextsSettings.tsx b/components/settings/InvoiceEmailTextsSettings.tsx new file mode 100644 index 00000000..9e4a4359 --- /dev/null +++ b/components/settings/InvoiceEmailTextsSettings.tsx @@ -0,0 +1,210 @@ +'use client' + +import { useCallback, useRef, useState } from 'react' +import { useTranslations } from 'next-intl' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Textarea } from '@/components/ui/textarea' +import { useToast } from '@/components/ui/use-toast' +import { useCanWrite } from '@/lib/hooks/use-can-write' +import { + INVOICE_EMAIL_DEFAULT_TEXTS, + INVOICE_EMAIL_PLACEHOLDER_KEYS, +} from '@/lib/email/invoice-templates' +import type { CompanySettings, InvoiceEmailTextOverrides, InvoiceEmailTexts } from '@/types' + +interface InvoiceEmailTextsSettingsProps { + settings: CompanySettings + onUpdate: (updates: Partial) => void +} + +type Lang = 'sv' | 'en' +type Field = keyof InvoiceEmailTextOverrides + +const LANGS: Lang[] = ['sv', 'en'] + +const FIELD_CONFIG: Array<{ field: Field; labelKey: string; multiline?: boolean }> = [ + { field: 'subject', labelKey: 'subject_label' }, + { field: 'greeting', labelKey: 'greeting_label' }, + { field: 'body', labelKey: 'body_label', multiline: true }, + { field: 'signoff', labelKey: 'signoff_label' }, +] + +// The editor always shows the EFFECTIVE text (override or standard), never an +// empty field — users see and edit the mail that actually goes out. +type DisplayTexts = Record> + +function buildDisplay(stored: InvoiceEmailTexts | null | undefined): DisplayTexts { + const result = {} as DisplayTexts + for (const lang of LANGS) { + result[lang] = {} as Record + for (const { field } of FIELD_CONFIG) { + const value = stored?.[lang]?.[field] + result[lang][field] = + typeof value === 'string' && value.trim() !== '' + ? value + : INVOICE_EMAIL_DEFAULT_TEXTS[lang][field] + } + } + return result +} + +// Cleared fields have no meaning of their own — snap them back to standard. +function normalize(display: DisplayTexts): DisplayTexts { + const result = {} as DisplayTexts + for (const lang of LANGS) { + result[lang] = {} as Record + for (const { field } of FIELD_CONFIG) { + const value = display[lang][field] + result[lang][field] = + value.trim() === '' ? INVOICE_EMAIL_DEFAULT_TEXTS[lang][field] : value + } + } + return result +} + +// Store only changes: a field equal to the standard text is NOT an override, +// so future improvements to the standard wording reach every company that +// hasn't customized. Empty result → null (column reads "all defaults"). +function toOverrides(display: DisplayTexts): InvoiceEmailTexts | null { + const result: InvoiceEmailTexts = {} + for (const lang of LANGS) { + const langOverrides: InvoiceEmailTextOverrides = {} + for (const { field } of FIELD_CONFIG) { + const value = display[lang][field].trim() + if (value !== '' && value !== INVOICE_EMAIL_DEFAULT_TEXTS[lang][field]) { + langOverrides[field] = value + } + } + if (Object.keys(langOverrides).length > 0) result[lang] = langOverrides + } + return Object.keys(result).length > 0 ? result : null +} + +export function InvoiceEmailTextsSettings({ settings, onUpdate }: InvoiceEmailTextsSettingsProps) { + const t = useTranslations('settings_email_texts') + const { toast } = useToast() + const { canWrite } = useCanWrite() + const [texts, setTexts] = useState(() => buildDisplay(settings.invoice_email_texts)) + // Serialized last-persisted overrides — skips no-op PUTs on blur without + // edits. toOverrides() builds keys in a fixed order, so comparison is stable. + const lastSavedRef = useRef( + JSON.stringify(toOverrides(buildDisplay(settings.invoice_email_texts))), + ) + + const setField = (lang: Lang, field: Field, value: string) => { + setTexts((prev) => ({ ...prev, [lang]: { ...prev[lang], [field]: value } })) + } + + // Whole-object save: a JSONB column update replaces the stored value, and + // the inactive language tab is unmounted, so per-field PATCHes can't work. + const persist = useCallback(async (display: DisplayTexts) => { + const overrides = toOverrides(display) + const serialized = JSON.stringify(overrides) + if (serialized === lastSavedRef.current) return + try { + const response = await fetch('/api/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ invoice_email_texts: overrides }), + }) + if (!response.ok) throw new Error() + lastSavedRef.current = serialized + onUpdate({ invoice_email_texts: overrides }) + } catch { + toast({ title: t('toast_save_failed'), variant: 'destructive' }) + } + }, [onUpdate, toast, t]) + + const handleBlur = () => { + const normalized = normalize(texts) + setTexts(normalized) + void persist(normalized) + } + + const resetField = (lang: Lang, field: Field) => { + const next = { + ...texts, + [lang]: { ...texts[lang], [field]: INVOICE_EMAIL_DEFAULT_TEXTS[lang][field] }, + } + setTexts(next) + void persist(next) + } + + return ( +
+
+

+ {t('heading')} +

+

{t('description')}

+
+ + + + {t('tab_sv')} + {t('tab_en')} + + {LANGS.map((lang) => ( + + {lang === 'en' && ( +

{t('en_tab_hint')}

+ )} + {FIELD_CONFIG.map(({ field, labelKey, multiline }) => { + const id = `invoice-email-${field}-${lang}` + const modified = + texts[lang][field].trim() !== INVOICE_EMAIL_DEFAULT_TEXTS[lang][field] + const common = { + id, + value: texts[lang][field], + onBlur: handleBlur, + disabled: !canWrite, + } + return ( +
+
+ + {modified && canWrite && ( + + )} +
+ {multiline ? ( +