diff --git a/app/(auth)/auth/callback/__tests__/route.test.ts b/app/(auth)/auth/callback/__tests__/route.test.ts new file mode 100644 index 00000000..e101e5b0 --- /dev/null +++ b/app/(auth)/auth/callback/__tests__/route.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextRequest } from 'next/server' + +const verifyOtp = vi.fn() +const exchangeCodeForSession = vi.fn() + +vi.mock('@supabase/ssr', () => ({ + createServerClient: vi.fn(() => ({ + auth: { + verifyOtp, + exchangeCodeForSession, + getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } } }), + mfa: { + getAuthenticatorAssuranceLevel: vi.fn().mockResolvedValue({ data: null }), + listFactors: vi.fn().mockResolvedValue({ data: null }), + }, + }, + from: vi.fn(), + rpc: vi.fn(), + })), +})) + +vi.mock('@/lib/auth/invite-tokens', () => ({ + hashInviteToken: vi.fn(), +})) + +import { GET } from '../route' + +describe('GET /auth/callback — recovery flow', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('redirects to /reset-password after a successful recovery OTP (token-hash flow)', async () => { + verifyOtp.mockResolvedValue({ error: null }) + + const request = new NextRequest( + 'http://localhost:3000/auth/callback?token_hash=abc&type=recovery&next=/reset-password' + ) + const response = await GET(request) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe('http://localhost:3000/reset-password') + expect(verifyOtp).toHaveBeenCalledWith({ token_hash: 'abc', type: 'recovery' }) + }) + + it('redirects to /reset-password after a successful PKCE exchange when next=/reset-password (no type param)', async () => { + exchangeCodeForSession.mockResolvedValue({ error: null }) + + const request = new NextRequest( + 'http://localhost:3000/auth/callback?code=xyz&next=/reset-password' + ) + const response = await GET(request) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe('http://localhost:3000/reset-password') + expect(exchangeCodeForSession).toHaveBeenCalledWith('xyz') + }) + + it('redirects to /login?error=auth_error when the recovery OTP is expired or already consumed', async () => { + verifyOtp.mockResolvedValue({ error: { message: 'Token has expired or is invalid' } }) + + const request = new NextRequest( + 'http://localhost:3000/auth/callback?token_hash=expired&type=recovery&next=/reset-password' + ) + const response = await GET(request) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe('http://localhost:3000/login?error=auth_error') + }) +}) diff --git a/app/(auth)/auth/callback/route.ts b/app/(auth)/auth/callback/route.ts index 26a819b0..224c9ac8 100644 --- a/app/(auth)/auth/callback/route.ts +++ b/app/(auth)/auth/callback/route.ts @@ -54,6 +54,19 @@ export async function GET(request: NextRequest) { if (authenticated) { let redirectPath = next + // Password recovery flow: the user just exchanged a recovery token, so they + // have a fresh session whose only purpose is to call updateUser({ password }) + // on /reset-password. Skip onboarding / team setup / dashboard redirect. + // The token-hash flow signals this via type=recovery; PKCE has no type, so + // also gate on next === '/reset-password' (only the reset request sets it). + if (type === 'recovery' || next === '/reset-password') { + const response = NextResponse.redirect(new URL('/reset-password', origin)) + for (const { name, value, options } of pendingCookies) { + response.cookies.set({ name, value, ...options }) + } + return response + } + const { data: { user } } = await supabase.auth.getUser() if (user) { // Check MFA status — redirect to verify if factor is enrolled but session is AAL1 diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 5cc9fbc6..d6bc1f9f 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -1,7 +1,7 @@ 'use client' -import { useState, useEffect } from 'react' -import { useRouter } from 'next/navigation' +import { Suspense, useState, useEffect } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' import Link from 'next/link' import { createClient } from '@/lib/supabase/client' import { Button } from '@/components/ui/button' @@ -18,7 +18,17 @@ import { getBranding } from '@/lib/branding/service' const branding = getBranding() import type { BankIdResult } from '@/components/auth/BankIdAuth' +// Wrapping in Suspense is required because useSearchParams() forces +// dynamic rendering in Next.js 16; static prerender bails out otherwise. export default function LoginPage() { + return ( + + + + ) +} + +function LoginPageContent() { const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [isLoading, setIsLoading] = useState(false) @@ -29,6 +39,8 @@ export default function LoginPage() { const [bankIdNoAccount, setBankIdNoAccount] = useState<{ givenName?: string; surname?: string } | null>(null) const { toast } = useToast() const router = useRouter() + const searchParams = useSearchParams() + const callbackError = searchParams.get('error') const supabase = createClient() const bankIdEnabled = isBankIdEnabled() @@ -353,6 +365,24 @@ export default function LoginPage() {
+ {callbackError === 'auth_error' && ( +
+

+ Återställningslänken fungerade inte +

+

+ Länken har gått ut eller använts redan.{' '} + + . +

+
+ )} {bankIdEnabled && ( <> {bankIdNoAccount ? ( diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/credit/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/[id]/credit/__tests__/route.test.ts new file mode 100644 index 00000000..e9f72274 --- /dev/null +++ b/app/api/v1/companies/[companyId]/invoices/[id]/credit/__tests__/route.test.ts @@ -0,0 +1,332 @@ +/** + * Integration tests for POST /api/v1/companies/:companyId/invoices/:id/credit. + */ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + if (process.env.NODE_ENV !== 'test') { + throw new Error( + `credit route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`, + ) + } + process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321' + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key' +}) + +vi.mock('@/lib/auth/api-keys', async () => { + const actual = await vi.importActual('@/lib/auth/api-keys') + return { + ...actual, + validateApiKey: vi.fn(), + createServiceClientNoCookies: vi.fn(), + } +}) +vi.mock('@supabase/supabase-js', async () => { + const actual = await vi.importActual('@supabase/supabase-js') + return { ...actual, createClient: vi.fn().mockReturnValue({}) } +}) + +vi.mock('@/lib/bookkeeping/invoice-entries', () => ({ + createCreditNoteJournalEntry: vi.fn().mockResolvedValue({ + id: 'mmmmmmmm-mmmm-4mmm-8mmm-mmmmmmmmmmmm', + }), +})) + +import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { + createCreditNoteJournalEntry as mockedCreditEntry, +} from '@/lib/bookkeeping/invoice-entries' +import { POST as creditInvoice } from '../route' + +const mockValidate = validateApiKey as ReturnType +const mockServiceClient = createServiceClientNoCookies as ReturnType +const mockCreditEntry = mockedCreditEntry as ReturnType + +type MockResult = { data?: unknown; error?: unknown } +function makeFlexibleSupabase(byTable: Record) { + const queues = new Map() + for (const [t, val] of Object.entries(byTable)) { + queues.set(t, Array.isArray(val) ? [...val] : [val]) + } + const buildChain = (table: string): unknown => { + const handler: ProxyHandler = { + get(_target, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => { + const q = queues.get(table) + const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null }) + resolve(next) + } + } + return (..._args: unknown[]) => buildChain(table) + }, + } + return new Proxy({}, handler) + } + return { from: vi.fn((table: string) => buildChain(table)) } +} + +const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +const INVOICE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +const USER_ID = 'user-1' + +function makeRequest(url: string, body?: unknown): Request { + return new Request(url, { + method: 'POST', + headers: { + Authorization: 'Bearer test-fixture-not-a-real-key', + 'Content-Type': 'application/json', + 'Idempotency-Key': 'idem1234-2020-4abc-8def-1234567890ab', + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) +} +function detailParams(companyId: string, id: string) { + return { params: Promise.resolve({ companyId, id }) } +} + +const ORIGINAL_SENT_INVOICE = { + id: INVOICE_ID, + invoice_number: '2026-0042', + customer_id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + invoice_date: '2026-05-12', + due_date: '2026-06-11', + status: 'sent', + document_type: 'invoice', + currency: 'SEK', + subtotal: 10000, + vat_amount: 2500, + total: 12500, + vat_treatment: 'standard_25', + moms_ruta: '05', + credited_invoice_id: null, + customer: { id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', name: 'Acme AB' }, + items: [{ sort_order: 0, description: 'x', quantity: 1, unit: 'st', unit_price: 10000, line_total: 10000, vat_rate: 25, vat_amount: 2500 }], +} + +const CREATED_CREDIT_NOTE = { + id: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', + invoice_number: 'KR-2026-0042', + customer_id: ORIGINAL_SENT_INVOICE.customer_id, + status: 'sent', + credited_invoice_id: INVOICE_ID, + total: -12500, + subtotal: -10000, + vat_amount: -2500, + document_type: 'invoice', +} + +beforeEach(() => { + vi.clearAllMocks() + mockValidate.mockResolvedValue({ + userId: USER_ID, + companyId: COMPANY_ID, + apiKeyId: 'ak_1', + apiKeyName: 'CI key', + scopes: ['invoices:write'], + mode: 'live', + }) +}) + +describe('POST /api/v1/companies/:companyId/invoices/:id/credit', () => { + it('issues a credit note with reversed amounts and posts the reverse journal entry', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: [ + { data: ORIGINAL_SENT_INVOICE, error: null }, // pre-flight read + { data: CREATED_CREDIT_NOTE, error: null }, // insert returning + ], + invoice_items: { data: null, error: null }, + company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }, + }), + ) + + const res = await creditInvoice( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/credit`, + { reason: 'Felaktig kund' }, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(201) + const body = await res.json() + expect(body.data.invoice_number).toBe('KR-2026-0042') + expect(body.data.credited_invoice_id).toBe(INVOICE_ID) + expect(body.data.total).toBe(-12500) + expect(body.data.journal_entry_id).toBe('mmmmmmmm-mmmm-4mmm-8mmm-mmmmmmmmmmmm') + expect(mockCreditEntry).toHaveBeenCalledTimes(1) + }) + + it('returns 404 INVOICE_CREDIT_ORIGINAL_NOT_FOUND when the original is missing', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: null, error: null }, + }), + ) + + const res = await creditInvoice( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/credit`, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error.code).toBe('INVOICE_CREDIT_ORIGINAL_NOT_FOUND') + }) + + it('returns 409 INVOICE_CREDIT_ALREADY_CREDITED when original.status=credited', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: { ...ORIGINAL_SENT_INVOICE, status: 'credited' }, error: null }, + }), + ) + + const res = await creditInvoice( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/credit`, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + // INVOICE_CREDIT_ALREADY_CREDITED is httpStatus 400 in the registry + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('INVOICE_CREDIT_ALREADY_CREDITED') + }) + + it('returns 400 INVOICE_CREDIT_NOT_SENT for drafts', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: { ...ORIGINAL_SENT_INVOICE, status: 'draft' }, error: null }, + }), + ) + + const res = await creditInvoice( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/credit`, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('INVOICE_CREDIT_NOT_SENT') + }) + + it('rejects crediting a credit note', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { + data: { ...ORIGINAL_SENT_INVOICE, credited_invoice_id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' }, + error: null, + }, + }), + ) + + const res = await creditInvoice( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/credit`, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('INVOICE_CREDIT_NOT_INVOICE') + }) + + it('rejects delivery notes', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: { ...ORIGINAL_SENT_INVOICE, document_type: 'delivery_note' }, error: null }, + }), + ) + + const res = await creditInvoice( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/credit`, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('INVOICE_CREDIT_NOT_INVOICE') + }) + + it('dry-run previews the credit note without inserting', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: ORIGINAL_SENT_INVOICE, error: null }, + company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }, + }), + ) + + const res = await creditInvoice( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/credit?dry_run=true`, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(200) + expect(res.headers.get('X-Dry-Run')).toBe('true') + const body = await res.json() + expect(body.data.dry_run).toBe(true) + expect(body.data.preview.total).toBe(-12500) + expect(body.data.preview.invoice_number).toBe('KR-2026-0042') + expect(body.data.preview.credited_invoice_id).toBe(INVOICE_ID) + expect(body.data.preview.would_create_journal_entry).toBe(true) + expect(mockCreditEntry).not.toHaveBeenCalled() + }) + + it('rejects keys without invoices:write scope', async () => { + mockValidate.mockResolvedValue({ + userId: USER_ID, + companyId: COMPANY_ID, + scopes: ['invoices:read'], + mode: 'live', + }) + mockServiceClient.mockReturnValue(makeFlexibleSupabase({})) + + const res = await creditInvoice( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/credit`, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(403) + }) + + it('rejects requests without Idempotency-Key', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const req = new Request( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/credit`, + { + method: 'POST', + headers: { Authorization: 'Bearer test-fixture-not-a-real-key' }, + }, + ) + + const res = await creditInvoice(req, detailParams(COMPANY_ID, INVOICE_ID)) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + }) +}) diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/credit/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/credit/route.ts new file mode 100644 index 00000000..24507be7 --- /dev/null +++ b/app/api/v1/companies/[companyId]/invoices/[id]/credit/route.ts @@ -0,0 +1,460 @@ +/** + * POST /api/v1/companies/{companyId}/invoices/{id}/credit + * + * Issues a credit note (kreditfaktura) against the invoice identified by `:id`. + * Per ML 17 kap 22–23§, a kreditfaktura references the original invoice's + * löpnummer and carries reversed-sign amounts. + * + * Behaviour: + * 1. Validates the target is a real invoice (document_type='invoice') and + * currently sent / paid / overdue (already-credited rows are rejected). + * 2. Creates a NEW invoice row with credited_invoice_id set, status='sent', + * invoice_number='KR-', and negated subtotal / vat / total. + * 3. Mirrors the items table with negated quantity + line_total + vat_amount. + * On items failure, rolls back the credit-note row (scoped DELETE). + * 4. Flips the original invoice to status='credited'. + * 5. Posts the reverse journal entry via createCreditNoteJournalEntry + * (accrual only — cash basis defers recognition to refund time). + * 6. Emits invoice.credited. + * + * Idempotent (mandatory Idempotency-Key). Dry-runnable. The credit-note row + * gets created via INSERT — under dry-run NO row is created. + * + * Optional body: { reason?: string } — populates the credit note's `notes` + * field. Defaults to "Krediterar faktura ". + */ + +import { z } from 'zod' +import { created } from '@/lib/api/v1/response' +import { dryRunPreview } from '@/lib/api/v1/dry-run' +import { registerEndpoint } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { createCreditNoteJournalEntry } from '@/lib/bookkeeping/invoice-entries' +import { eventBus } from '@/lib/events' +import type { AccountingMethod, CreditNote, EntityType, Invoice } from '@/types' + +const CreditNoteRequest = z.object({ + reason: z.string().max(2000).optional(), +}) + +const ORIGINAL_INVOICE_COLUMNS = + 'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type' + +const CREDIT_NOTE_RESPONSE_COLUMNS = + 'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type, paid_at, paid_amount, remaining_amount, created_at, updated_at' + +const ORIGINAL_ITEMS_COLUMNS = + 'sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount' + +const CreditNoteCreated = z.object({ + id: z.string().uuid(), + invoice_number: z.string(), + credited_invoice_id: z.string().uuid(), + status: z.literal('sent'), + total: z.number(), + journal_entry_id: z.string().uuid().nullable(), + warnings: z + .array(z.object({ code: z.string(), message: z.string() })) + .optional(), +}) + +registerEndpoint({ + operation: 'invoices.credit', + method: 'POST', + path: '/api/v1/companies/:companyId/invoices/:id/credit', + summary: 'Issue a credit note (kreditfaktura) against an invoice.', + description: + 'Creates a credit note referencing the original invoice. The credit note carries reversed-sign amounts (matching the original line for line) and gets invoice_number=KR-. The original invoice transitions to status=credited. Under faktureringsmetoden, posts a reversing journal entry (Credit AR 1510 / Debit revenue + Debit output VAT). Under kontantmetoden the credit note still creates the row but defers the reversal entry until refund. Idempotent and dry-runnable. Emits invoice.credited.', + useWhen: + 'You need to legally cancel an issued invoice (ML 17 kap 22–23§). The original invoice cannot be edited once issued — credit it and reissue corrected.', + doNotUseFor: + 'Cancelling a draft (DELETE the draft instead). Refunding a partial payment without invalidating the whole invoice (book the refund manually via the journal-entries API in a future PR).', + pitfalls: [ + 'Idempotency-Key is mandatory. Retried credits with the same key replay the cached response — no duplicate credit note is created.', + 'The original invoice must be in sent / paid / overdue status. Drafts, cancelled invoices, and already-credited invoices are rejected with specific error codes.', + 'Credit-note items mirror the original\'s lines with negated values. To credit only part of an invoice (line-level), credit the full invoice first then reissue with the corrected lines.', + 'Under kontantmetoden no journal entry is created here — refund booking is deferred. A `JOURNAL_ENTRY_NOT_POSTED` warning is NOT emitted in this case (the deferral is correct, not a failure).', + ], + example: { + request: { reason: 'Felaktig kund' }, + response: { + data: { + id: 'ccccccc-c…', + invoice_number: 'KR-2026-0042', + credited_invoice_id: '0e9c…', + status: 'sent', + total: -12500, + journal_entry_id: '8b4b…', + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'invoices:write', + risk: 'high', + idempotent: true, + reversible: false, + dryRunSupported: true, + request: { body: CreditNoteRequest }, + response: { success: CreditNoteCreated }, +}) + +export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'invoices.credit', + async (request, ctx, params) => { + const { id } = await params.params + + const idParse = z.string().uuid().safeParse(id) + if (!idParse.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'id', message: 'Invoice id must be a UUID.' }, + }) + } + const originalId = idParse.data + + if (!z.string().uuid().safeParse(ctx.companyId).success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'companyId', message: 'companyId must be a UUID.' }, + }) + } + + // Body is optional. Empty POST is valid (uses default notes). + let rawBody: unknown = null + try { + const text = await request.text() + if (text.trim()) rawBody = JSON.parse(text) + } catch { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'body', message: 'Body is not valid JSON.' }, + }) + } + + let reason: string | undefined + if (rawBody) { + const parsed = CreditNoteRequest.safeParse(rawBody) + if (!parsed.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + issues: parsed.error.issues.map((i) => ({ + field: i.path.join('.'), + message: i.message, + })), + }, + }) + } + reason = parsed.data.reason + } + + // Pre-flight: fetch original invoice + items. + const { data: originalInvoice, error: fetchErr } = await ctx.supabase + .from('invoices') + .select(`${ORIGINAL_INVOICE_COLUMNS}, customer:customers(id, name), items:invoice_items(${ORIGINAL_ITEMS_COLUMNS})`) + .eq('company_id', ctx.companyId!) + .eq('id', originalId) + .maybeSingle() + + if (fetchErr) { + return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId }) + } + if (!originalInvoice) { + ctx.log.warn('invoices.credit: original not found', { + invoiceId: originalId, + companyId: ctx.companyId, + }) + return v1ErrorResponseFromCode('INVOICE_CREDIT_ORIGINAL_NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + }) + } + + type OriginalShape = Invoice & { + customer?: { name?: string } + items?: Array<{ + sort_order: number + description: string + quantity: number + unit: string + unit_price: number + line_total: number + vat_rate?: number | null + vat_amount?: number | null + }> + } + const original = originalInvoice as unknown as OriginalShape + + // Document-shape guards. + if (original.document_type && original.document_type !== 'invoice') { + return v1ErrorResponseFromCode('INVOICE_CREDIT_NOT_INVOICE', ctx.log, { + requestId: ctx.requestId, + details: { document_type: original.document_type }, + }) + } + if (original.credited_invoice_id) { + // Original IS itself a credit note — can't credit a credit. + return v1ErrorResponseFromCode('INVOICE_CREDIT_NOT_INVOICE', ctx.log, { + requestId: ctx.requestId, + details: { reason: 'cannot credit a credit note' }, + }) + } + if (original.status === 'credited') { + return v1ErrorResponseFromCode('INVOICE_CREDIT_ALREADY_CREDITED', ctx.log, { + requestId: ctx.requestId, + }) + } + if (!['sent', 'paid', 'overdue'].includes(original.status)) { + return v1ErrorResponseFromCode('INVOICE_CREDIT_NOT_SENT', ctx.log, { + requestId: ctx.requestId, + details: { current_status: original.status }, + }) + } + + const today = new Date().toISOString().split('T')[0] + const creditNoteNumber = `KR-${original.invoice_number ?? original.id.slice(0, 8)}` + const negate = (n: number | null | undefined): number => + n == null ? 0 : -Math.abs(n) + const negateNullable = (n: number | null | undefined): number | null => + n == null ? null : -Math.abs(n) + + // Compute credit-note items + totals up front (used in both dry-run and commit). + const creditNoteRow = { + user_id: ctx.userId, + company_id: ctx.companyId!, + customer_id: original.customer_id, + invoice_number: creditNoteNumber, + invoice_date: today, + due_date: today, + delivery_date: original.delivery_date ?? null, + currency: original.currency, + exchange_rate: original.exchange_rate ?? null, + exchange_rate_date: original.exchange_rate_date ?? null, + subtotal: negate(original.subtotal), + subtotal_sek: negateNullable(original.subtotal_sek), + vat_amount: negate(original.vat_amount), + vat_amount_sek: negateNullable(original.vat_amount_sek), + total: negate(original.total), + total_sek: negateNullable(original.total_sek), + vat_treatment: original.vat_treatment, + vat_rate: original.vat_rate, + moms_ruta: original.moms_ruta, + reverse_charge_text: original.reverse_charge_text ?? null, + your_reference: original.your_reference ?? null, + our_reference: original.our_reference ?? null, + notes: reason || `Krediterar faktura ${original.invoice_number ?? original.id}`, + credited_invoice_id: originalId, + status: 'sent' as const, + document_type: 'invoice' as const, + } + + const creditNoteItems = (original.items ?? []).map((item) => ({ + sort_order: item.sort_order, + description: item.description, + quantity: -Math.abs(item.quantity), + unit: item.unit, + unit_price: item.unit_price, + line_total: -Math.abs(item.line_total), + vat_rate: item.vat_rate ?? 0, + vat_amount: -Math.abs(item.vat_amount ?? 0), + })) + + // Fetch settings for accounting method + entity type. + const { data: settings } = await ctx.supabase + .from('company_settings') + .select('accounting_method, entity_type') + .eq('company_id', ctx.companyId!) + .maybeSingle() + const accountingMethod = + ((settings as { accounting_method?: string } | null)?.accounting_method ?? + 'accrual') as AccountingMethod + const entityType = ((settings as { entity_type?: string } | null)?.entity_type ?? + 'enskild_firma') as EntityType + const wouldCreateJournalEntry = accountingMethod === 'accrual' + + if (ctx.dryRun) { + return dryRunPreview( + { + id: '(allocated on commit)', + ...creditNoteRow, + // Strip internal ids from the preview. + user_id: undefined, + company_id: undefined, + items: creditNoteItems, + would_create_journal_entry: wouldCreateJournalEntry, + accounting_method: accountingMethod, + original_invoice_number: original.invoice_number, + }, + { requestId: ctx.requestId, log: ctx.log }, + ) + } + + // Commit. Step 1: insert credit note header. + const { data: creditNote, error: insertErr } = await ctx.supabase + .from('invoices') + .insert(creditNoteRow) + .select(CREDIT_NOTE_RESPONSE_COLUMNS) + .single() + if (insertErr) { + ctx.log.error('credit-note insert failed', insertErr, { + invoiceId: originalId, + companyId: ctx.companyId, + pgCode: insertErr.code, + }) + return v1ErrorResponseFromCode('INVOICE_CREATE_INSERT_FAILED', ctx.log, { + requestId: ctx.requestId, + details: { pg_code: insertErr.code }, + }) + } + const creditNoteId = (creditNote as { id: string }).id + + // Step 2: insert items. Roll back on failure. + const itemsToInsert = creditNoteItems.map((r) => ({ ...r, invoice_id: creditNoteId })) + const { error: itemsErr } = await ctx.supabase.from('invoice_items').insert(itemsToInsert) + if (itemsErr) { + const { error: rollbackErr } = await ctx.supabase + .from('invoices') + .delete() + .eq('id', creditNoteId) + .eq('company_id', ctx.companyId!) + if (rollbackErr) { + ctx.log.error( + 'credit-note items insert failed AND rollback delete failed — orphaned header', + rollbackErr, + { + creditNoteId, + originalInvoiceId: originalId, + companyId: ctx.companyId, + originalPgCode: itemsErr.code, + }, + ) + } else { + ctx.log.error('credit-note items insert failed; rolled back', itemsErr, { + creditNoteId, + companyId: ctx.companyId, + }) + } + return v1ErrorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', ctx.log, { + requestId: ctx.requestId, + details: { pg_code: itemsErr.code }, + }) + } + + // Step 3: flip original invoice to credited. + const warnings: { code: string; message: string }[] = [] + const { error: flipErr } = await ctx.supabase + .from('invoices') + .update({ status: 'credited', updated_at: new Date().toISOString() }) + .eq('id', originalId) + .eq('company_id', ctx.companyId!) + if (flipErr) { + ctx.log.error('credit: failed to mark original as credited', flipErr as Error, { + invoiceId: originalId, + creditNoteId, + companyId: ctx.companyId, + }) + warnings.push({ + code: 'ORIGINAL_NOT_FLIPPED', + message: 'Credit note was created but the original invoice could not be marked credited. Reconcile manually.', + }) + } + + // Step 4: post the reverse journal entry (accrual only). Best-effort. + let journalEntryId: string | null = null + if (wouldCreateJournalEntry) { + try { + const refreshedCreditNote = { + ...creditNote, + items: itemsToInsert, + customer: original.customer, + } as unknown as Invoice + const entry = await createCreditNoteJournalEntry( + ctx.supabase, + ctx.companyId!, + ctx.userId, + refreshedCreditNote, + entityType, + original.customer?.name, + ) + if (entry) { + journalEntryId = entry.id + const { error: writeBackErr } = await ctx.supabase + .from('invoices') + .update({ journal_entry_id: entry.id }) + .eq('id', creditNoteId) + .eq('company_id', ctx.companyId!) + if (writeBackErr) { + ctx.log.error('credit: journal_entry_id write-back failed', writeBackErr as Error, { + creditNoteId, + journalEntryId: entry.id, + }) + warnings.push({ + code: 'JOURNAL_ENTRY_ID_WRITEBACK_FAILED', + message: 'Credit-note journal entry was posted but the row could not be updated with its id. Re-fetch and reconcile.', + }) + } + } else { + ctx.log.error('credit: journal entry not created (engine returned null)', new Error('null entry'), { + creditNoteId, + }) + warnings.push({ + code: 'JOURNAL_ENTRY_NOT_POSTED', + message: 'Credit note was created but no journal entry was posted. Check fiscal period and the engine logs (BFL 5 kap reconciliation required).', + }) + } + } catch (err) { + ctx.log.error('credit: journal entry creation failed', err as Error, { + creditNoteId, + companyId: ctx.companyId, + }) + warnings.push({ + code: 'JOURNAL_ENTRY_NOT_POSTED', + message: 'Credit note was created but the journal entry failed. Reconcile manually.', + }) + } + } + + // Step 5: emit credit_note.created (existing event in the bus). The + // payload carries the new credit note; subscribers can read + // credited_invoice_id off it to find the original. + try { + await eventBus.emit({ + type: 'credit_note.created', + payload: { + creditNote: { ...(creditNote as object), customer: original.customer } as unknown as CreditNote, + companyId: ctx.companyId!, + userId: ctx.userId, + }, + }) + } catch (err) { + ctx.log.error('credit_note.created emit failed', err as Error, { + creditNoteId, + companyId: ctx.companyId, + }) + warnings.push({ + code: 'EVENT_EMIT_FAILED', + message: 'credit_note.created event did not reach the bus; downstream subscribers may miss this transition.', + }) + } + + ctx.log.info('invoices.credit success', { + creditNoteId, + originalInvoiceId: originalId, + companyId: ctx.companyId, + userId: ctx.userId, + creditNoteNumber, + journalEntryId, + hadWarnings: warnings.length > 0, + }) + + return created( + { + ...(creditNote as object), + journal_entry_id: journalEntryId, + ...(warnings.length > 0 ? { warnings } : {}), + }, + { requestId: ctx.requestId }, + ) + }, + { requireIdempotencyKey: true }, +) diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/__tests__/route.test.ts new file mode 100644 index 00000000..ed628451 --- /dev/null +++ b/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/__tests__/route.test.ts @@ -0,0 +1,331 @@ +/** + * Integration tests for POST /api/v1/companies/:companyId/invoices/:id/mark-paid. + */ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + if (process.env.NODE_ENV !== 'test') { + throw new Error( + `mark-paid route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`, + ) + } + process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321' + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key' +}) + +vi.mock('@/lib/auth/api-keys', async () => { + const actual = await vi.importActual('@/lib/auth/api-keys') + return { + ...actual, + validateApiKey: vi.fn(), + createServiceClientNoCookies: vi.fn(), + } +}) +vi.mock('@supabase/supabase-js', async () => { + const actual = await vi.importActual('@supabase/supabase-js') + return { ...actual, createClient: vi.fn().mockReturnValue({}) } +}) + +// Stub the journal-entry helpers; route flow is what we're testing. +vi.mock('@/lib/bookkeeping/invoice-entries', () => ({ + createInvoicePaymentJournalEntry: vi.fn().mockResolvedValue({ + id: 'jjjjjjjj-jjjj-4jjj-8jjj-jjjjjjjjjjjj', + }), + createInvoiceCashEntry: vi.fn().mockResolvedValue({ + id: 'kkkkkkkk-kkkk-4kkk-8kkk-kkkkkkkkkkkk', + }), +})) +vi.mock('@/lib/bookkeeping/engine', () => ({ + createJournalEntry: vi.fn().mockResolvedValue({ + id: 'llllllll-llll-4lll-8lll-llllllllllll', + }), + findFiscalPeriod: vi.fn().mockResolvedValue('fp-1'), +})) + +import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { + createInvoicePaymentJournalEntry as mockedPayment, + createInvoiceCashEntry as mockedCash, +} from '@/lib/bookkeeping/invoice-entries' +import { POST as markPaid } from '../route' + +const mockValidate = validateApiKey as ReturnType +const mockServiceClient = createServiceClientNoCookies as ReturnType +const mockPayment = mockedPayment as ReturnType +const mockCash = mockedCash as ReturnType + +type MockResult = { data?: unknown; error?: unknown } +function makeFlexibleSupabase(byTable: Record) { + const queues = new Map() + for (const [t, val] of Object.entries(byTable)) { + queues.set(t, Array.isArray(val) ? [...val] : [val]) + } + const buildChain = (table: string): unknown => { + const handler: ProxyHandler = { + get(_target, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => { + const q = queues.get(table) + const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null }) + resolve(next) + } + } + return (..._args: unknown[]) => buildChain(table) + }, + } + return new Proxy({}, handler) + } + return { from: vi.fn((table: string) => buildChain(table)) } +} + +const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +const INVOICE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +const USER_ID = 'user-1' + +function makeRequest(url: string, body?: unknown): Request { + return new Request(url, { + method: 'POST', + headers: { + Authorization: 'Bearer test-fixture-not-a-real-key', + 'Content-Type': 'application/json', + 'Idempotency-Key': 'idem1234-1010-4abc-8def-1234567890ab', + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) +} +function detailParams(companyId: string, id: string) { + return { params: Promise.resolve({ companyId, id }) } +} + +const SENT_INVOICE = { + id: INVOICE_ID, + invoice_number: '2026-0042', + customer_id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + invoice_date: '2026-05-12', + due_date: '2026-06-11', + status: 'sent', + document_type: 'invoice', + currency: 'SEK', + subtotal: 10000, + vat_amount: 2500, + total: 12500, + remaining_amount: 12500, + paid_amount: 0, + paid_at: null, + vat_treatment: 'standard_25', + moms_ruta: '05', + credited_invoice_id: null, + customer: { id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', name: 'Acme AB' }, + items: [{ sort_order: 0, description: 'x', quantity: 1, unit: 'st', unit_price: 10000, line_total: 10000, vat_rate: 25, vat_amount: 2500 }], +} +const PAID_INVOICE = { + ...SENT_INVOICE, + status: 'paid', + remaining_amount: 0, + paid_amount: 12500, + paid_at: '2026-05-12', +} + +beforeEach(() => { + vi.clearAllMocks() + mockValidate.mockResolvedValue({ + userId: USER_ID, + companyId: COMPANY_ID, + apiKeyId: 'ak_1', + apiKeyName: 'CI key', + scopes: ['invoices:write'], + mode: 'live', + }) +}) + +describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => { + it('books a full payment under faktureringsmetoden (accrual default)', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: [ + { data: SENT_INVOICE, error: null }, + { data: PAID_INVOICE, error: null }, + ], + company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }, + }), + ) + + const res = await markPaid( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-paid`, + { payment_date: '2026-05-12' }, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.status).toBe('paid') + expect(body.data.remaining_amount).toBe(0) + expect(body.data.journal_entry_id).toBe('jjjjjjjj-jjjj-4jjj-8jjj-jjjjjjjjjjjj') + expect(mockPayment).toHaveBeenCalled() + expect(mockCash).not.toHaveBeenCalled() + }) + + it('uses the cash-basis booking when accounting_method=cash', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: [ + { data: SENT_INVOICE, error: null }, + { data: PAID_INVOICE, error: null }, + ], + company_settings: { data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null }, + }), + ) + + const res = await markPaid( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-paid`, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(200) + expect(mockCash).toHaveBeenCalled() + expect(mockPayment).not.toHaveBeenCalled() + }) + + it('returns 400 INVOICE_PAID_LINES_UNBALANCED when custom lines do not balance', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: SENT_INVOICE, error: null }, + company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }, + }), + ) + + const res = await markPaid( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-paid`, + { + lines: [ + { account_number: '1930', debit_amount: 5000, credit_amount: 0 }, + { account_number: '1510', debit_amount: 0, credit_amount: 4000 }, // unbalanced + ], + }, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('INVOICE_PAID_LINES_UNBALANCED') + }) + + it('returns 400 INVOICE_PAID_NOT_PAYABLE for draft invoices', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: { ...SENT_INVOICE, status: 'draft' }, error: null }, + }), + ) + + const res = await markPaid( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-paid`, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('INVOICE_PAID_NOT_PAYABLE') + }) + + it('rejects credit notes', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { + data: { ...SENT_INVOICE, credited_invoice_id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' }, + error: null, + }, + }), + ) + + const res = await markPaid( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-paid`, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + expect(body.error.details.field).toBe('credited_invoice_id') + }) + + it('dry-run previews the post-payment state without booking', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: SENT_INVOICE, error: null }, + company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }, + }), + ) + + const res = await markPaid( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-paid?dry_run=true`, + { payment_date: '2026-05-12' }, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(200) + expect(res.headers.get('X-Dry-Run')).toBe('true') + const body = await res.json() + expect(body.data.dry_run).toBe(true) + expect(body.data.preview.status).toBe('paid') + expect(body.data.preview.remaining_amount).toBe(0) + expect(body.data.preview.would_create_journal_entry).toBe(true) + expect(mockPayment).not.toHaveBeenCalled() + }) + + it('returns 404 INVOICE_PAID_NOT_FOUND when invoice does not belong to company', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: null, error: null }, + }), + ) + + const res = await markPaid( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-paid`, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error.code).toBe('INVOICE_PAID_NOT_FOUND') + }) + + it('rejects keys without invoices:write scope', async () => { + mockValidate.mockResolvedValue({ + userId: USER_ID, + companyId: COMPANY_ID, + scopes: ['invoices:read'], + mode: 'live', + }) + mockServiceClient.mockReturnValue(makeFlexibleSupabase({})) + + const res = await markPaid( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-paid`, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(403) + }) +}) diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts new file mode 100644 index 00000000..399b367e --- /dev/null +++ b/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts @@ -0,0 +1,460 @@ +/** + * POST /api/v1/companies/{companyId}/invoices/{id}/mark-paid + * + * Manually marks an invoice as paid — for payments received outside the + * bank-sync flow. + * + * Accounting: + * - Faktureringsmetoden (accrual): Debit 1930 / Credit 1510. The invoice + * was already booked as revenue at :mark-sent; this just settles the AR. + * - Kontantmetoden (cash): Debit 1930 / Credit 30xx + Credit 26xx. Revenue + * recognition happens here (no entry at :mark-sent under cash basis). + * + * Optional request body (all fields optional — empty POST = book full payment + * on today's date with default lines): + * - payment_date ISO date; defaults to today + * - exchange_rate_difference SEK adjustment for foreign-currency invoices + * - lines Custom balanced journal lines (partial payments) + * + * Idempotent (mandatory Idempotency-Key). Dry-runnable. + * + * On commit: + * 1. Build journal entry (default 1930/1510 split, or custom lines). + * 2. Post via createInvoicePaymentJournalEntry / createJournalEntry. + * 3. Update invoice: status → 'paid' (or 'partially_paid' for partial), + * remaining_amount decremented, paid_at set, paid_amount accumulated. + * 4. Emit invoice.paid. + */ + +import { z } from 'zod' +import { ok } from '@/lib/api/v1/response' +import { dryRunPreview } from '@/lib/api/v1/dry-run' +import { registerEndpoint } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { MarkInvoicePaidSchema } from '@/lib/api/schemas' +import { + createInvoiceCashEntry, + createInvoicePaymentJournalEntry, +} from '@/lib/bookkeeping/invoice-entries' +import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine' +import { eventBus } from '@/lib/events' +import type { CreateJournalEntryInput, EntityType, Invoice } from '@/types' + +const INVOICE_MARK_PAID_RESPONSE_COLUMNS = + 'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, created_at, updated_at' + +const InvoiceMarkPaidResponse = z.object({ + id: z.string().uuid(), + invoice_number: z.string(), + status: z.enum(['paid', 'partially_paid']), + total: z.number(), + paid_amount: z.number(), + remaining_amount: z.number(), + paid_at: z.string().nullable(), + journal_entry_id: z.string().uuid().nullable(), + warnings: z + .array(z.object({ code: z.string(), message: z.string() })) + .optional(), +}) + +registerEndpoint({ + operation: 'invoices.mark-paid', + method: 'POST', + path: '/api/v1/companies/:companyId/invoices/:id/mark-paid', + summary: 'Record a payment against an invoice.', + description: + 'Marks a sent / overdue invoice as paid (or partially_paid). Books the payment via Debit 1930 / Credit 1510 under faktureringsmetoden, or Debit 1930 / Credit revenue + Credit output VAT under kontantmetoden. Optional body supports partial payments via custom balanced journal lines and exchange-rate adjustments for foreign-currency invoices. Idempotent and dry-runnable. Emits invoice.paid.', + useWhen: + 'A customer paid an invoice via a channel other than the synced bank account (cash, manual transfer, separate processor). Use dry-run to confirm the booking before committing.', + doNotUseFor: + 'Reverting a payment — the public API does not expose unmark-paid. Issue a credit note via POST /:id/credit to cancel the underlying invoice instead. Bank-matched payments — those flow through the transactions endpoints.', + pitfalls: [ + 'Idempotency-Key is mandatory. Retried marks with the same key replay the cached response.', + 'Custom `lines` must balance (sum of debits = sum of credits, both > 0). Otherwise returns 400 INVOICE_PAID_LINES_UNBALANCED.', + 'For foreign-currency invoices, supply `exchange_rate_difference` (SEK delta vs the invoice\'s booked rate) to book the FX adjustment correctly. Omitting it on a non-SEK invoice will mis-book the FX gain/loss.', + 'Cash basis (kontantmetoden) recognizes revenue HERE, not at :mark-sent. The dashboard tracks this via company_settings.accounting_method.', + ], + example: { + request: { payment_date: '2026-05-12' }, + response: { + data: { + id: '0e9c…', + invoice_number: '2026-0042', + status: 'paid', + total: 12500, + paid_amount: 12500, + remaining_amount: 0, + paid_at: '2026-05-12', + journal_entry_id: '7b3a…', + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'invoices:write', + risk: 'medium', + idempotent: true, + reversible: false, + dryRunSupported: true, + request: { body: MarkInvoicePaidSchema }, + response: { success: InvoiceMarkPaidResponse }, +}) + +export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'invoices.mark-paid', + async (request, ctx, params) => { + const { id } = await params.params + + const idParse = z.string().uuid().safeParse(id) + if (!idParse.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'id', message: 'Invoice id must be a UUID.' }, + }) + } + const invoiceId = idParse.data + + if (!z.string().uuid().safeParse(ctx.companyId).success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'companyId', message: 'companyId must be a UUID.' }, + }) + } + + // Body is optional. Empty POST → book full payment today. + let rawBody: unknown = null + try { + const text = await request.text() + if (text.trim()) rawBody = JSON.parse(text) + } catch { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'body', message: 'Body is not valid JSON.' }, + }) + } + + let exchangeRateDifference: number | undefined + let bodyPaymentDate: string | undefined + let customLines: + | { + account_number: string + debit_amount: number + credit_amount: number + line_description?: string + }[] + | undefined + if (rawBody) { + const parsed = MarkInvoicePaidSchema.safeParse(rawBody) + if (!parsed.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + issues: parsed.error.issues.map((i) => ({ + field: i.path.join('.'), + message: i.message, + })), + }, + }) + } + exchangeRateDifference = parsed.data.exchange_rate_difference + bodyPaymentDate = parsed.data.payment_date + customLines = parsed.data.lines + } + + // Pre-flight: fetch invoice with relations needed for journal entry. + const { data: invoice, error: fetchErr } = await ctx.supabase + .from('invoices') + .select( + `${INVOICE_MARK_PAID_RESPONSE_COLUMNS}, customer:customers(id, name, customer_type), items:invoice_items(id, sort_order, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount)`, + ) + .eq('company_id', ctx.companyId!) + .eq('id', invoiceId) + .maybeSingle() + + if (fetchErr) { + return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId }) + } + if (!invoice) { + ctx.log.warn('invoices.mark-paid: not found', { invoiceId, companyId: ctx.companyId }) + return v1ErrorResponseFromCode('INVOICE_PAID_NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + }) + } + + const typed = invoice as unknown as Invoice & { customer?: { name?: string } } + + // Document-shape guards before status check (consistent with mark-sent). + if (typed.document_type === 'delivery_note') { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + field: 'document_type', + message: 'Delivery notes do not have payment lifecycle.', + }, + }) + } + if (typed.credited_invoice_id) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + field: 'credited_invoice_id', + message: 'Credit notes cannot be marked paid; the original invoice they credit was already accounted for.', + }, + }) + } + + if (typed.status !== 'sent' && typed.status !== 'overdue') { + return v1ErrorResponseFromCode('INVOICE_PAID_NOT_PAYABLE', ctx.log, { + requestId: ctx.requestId, + details: { current_status: typed.status }, + }) + } + + // Validate custom lines balance (if supplied). + if (customLines) { + const totalDebit = customLines.reduce((s, l) => s + l.debit_amount, 0) + const totalCredit = customLines.reduce((s, l) => s + l.credit_amount, 0) + if (Math.round((totalDebit - totalCredit) * 100) !== 0 || totalDebit <= 0) { + return v1ErrorResponseFromCode('INVOICE_PAID_LINES_UNBALANCED', ctx.log, { + requestId: ctx.requestId, + details: { total_debit: totalDebit, total_credit: totalCredit }, + }) + } + } + + const today = new Date().toISOString().split('T')[0] + const paymentDate = bodyPaymentDate || today + + // Fetch settings for accounting method + entity type. + const { data: settings } = await ctx.supabase + .from('company_settings') + .select('accounting_method, entity_type') + .eq('company_id', ctx.companyId!) + .maybeSingle() + const accountingMethod = + (settings as { accounting_method?: string } | null)?.accounting_method ?? 'accrual' + const entityType = ((settings as { entity_type?: string } | null)?.entity_type ?? + 'enskild_firma') as EntityType + + // Compute the would-be payment amount. Default path (no customLines): + // use remaining_amount, not total — protects against over-crediting AR + // when a concurrent partial payment slips through the pre-flight check + // (pre-flight sees status='sent' but the race-guard UPDATE later sees + // status='partially_paid' so a second full-total amount would be booked + // against an already-reduced AR balance). + const paymentAmount = customLines + ? customLines.reduce((s, l) => s + l.debit_amount, 0) + : (typed.remaining_amount ?? typed.total) + + const isPartial = + customLines !== undefined && + Math.abs(paymentAmount - (typed.remaining_amount ?? typed.total)) > 0.005 // same half-öre epsilon as above + + const newRemaining = Math.max( + 0, + Math.round(((typed.remaining_amount ?? typed.total) - paymentAmount) * 100) / 100, + ) + // 0.005 epsilon = half an öre. After rounding to 2 decimals above, + // newRemaining is in steps of 0.01; values ≤ 0.005 only arise from + // floating-point artefacts (e.g. 0.0000000001 from a SEK 99.99 payment + // against a SEK 99.99 invoice). Treating those as 'paid' avoids + // permanently-partially_paid invoices on full payment. + const newStatus: 'paid' | 'partially_paid' = newRemaining <= 0.005 ? 'paid' : 'partially_paid' + const newPaidAmount = + Math.round(((typed.paid_amount ?? 0) + paymentAmount) * 100) / 100 + + if (ctx.dryRun) { + return dryRunPreview( + { + ...typed, + status: newStatus, + paid_amount: newPaidAmount, + remaining_amount: newRemaining, + paid_at: paymentDate, + would_create_journal_entry: !typed.document_type || typed.document_type === 'invoice', + accounting_method: accountingMethod, + would_use_custom_lines: customLines !== undefined, + }, + { requestId: ctx.requestId, log: ctx.log }, + ) + } + + const warnings: { code: string; message: string }[] = [] + + // Commit path. Step 1: book the journal entry. Three flavors: + // - Custom lines (partial payment etc.) → createJournalEntry directly + // - Cash basis → createInvoiceCashEntry (recognizes revenue here) + // - Accrual basis → createInvoicePaymentJournalEntry (settles AR) + let journalEntryId: string | null = null + const isRealInvoice = !typed.document_type || typed.document_type === 'invoice' + if (isRealInvoice) { + try { + if (customLines) { + const fiscalPeriodId = await findFiscalPeriod( + ctx.supabase, + ctx.companyId!, + paymentDate, + ) + if (!fiscalPeriodId) { + return v1ErrorResponseFromCode('INVOICE_PAID_NO_FISCAL_PERIOD', ctx.log, { + requestId: ctx.requestId, + details: { payment_date: paymentDate }, + }) + } + const input: CreateJournalEntryInput = { + fiscal_period_id: fiscalPeriodId, + entry_date: paymentDate, + description: `Delbetalning faktura ${typed.invoice_number ?? typed.id}`, + source_type: 'invoice_paid', + source_id: invoiceId, + lines: customLines.map((l) => ({ + account_number: l.account_number, + debit_amount: l.debit_amount, + credit_amount: l.credit_amount, + line_description: l.line_description ?? undefined, + })), + } + const entry = await createJournalEntry( + ctx.supabase, + ctx.companyId!, + ctx.userId, + input, + ) + journalEntryId = entry?.id ?? null + } else if (accountingMethod === 'cash') { + const entry = await createInvoiceCashEntry( + ctx.supabase, + ctx.companyId!, + ctx.userId, + typed as Invoice, + paymentDate, + entityType, + typed.customer?.name, + ) + journalEntryId = entry?.id ?? null + } else { + const entry = await createInvoicePaymentJournalEntry( + ctx.supabase, + ctx.companyId!, + ctx.userId, + typed as Invoice, + paymentDate, + exchangeRateDifference, + typed.customer?.name, + // Pass full or partial amount depending on path. + customLines ? paymentAmount : undefined, + ) + journalEntryId = entry?.id ?? null + } + + if (!journalEntryId) { + warnings.push({ + code: 'JOURNAL_ENTRY_NOT_POSTED', + message: + 'Payment journal entry was not created (likely no open fiscal period). Verify the period and book manually if required.', + }) + } + } catch (err) { + ctx.log.error('mark-paid: journal entry creation failed', err as Error, { + invoiceId, + companyId: ctx.companyId, + }) + warnings.push({ + code: 'JOURNAL_ENTRY_NOT_POSTED', + message: + 'Payment was recorded but the journal entry posting failed. Check the engine logs; reconcile before period close.', + }) + } + } + + // Step 2: update the invoice row. + const updatePayload: Record = { + status: newStatus, + remaining_amount: newRemaining, + paid_amount: newPaidAmount, + updated_at: new Date().toISOString(), + } + if (newStatus === 'paid') { + updatePayload.paid_at = paymentDate + } + if (journalEntryId) { + updatePayload.journal_entry_id = journalEntryId + } + + const { data: updated, error: updateErr } = await ctx.supabase + .from('invoices') + .update(updatePayload) + .eq('company_id', ctx.companyId!) + .eq('id', invoiceId) + // Race guard: only flip from a payable status. + .in('status', ['sent', 'overdue', 'partially_paid']) + .select(INVOICE_MARK_PAID_RESPONSE_COLUMNS) + .maybeSingle() + + if (updateErr) { + ctx.log.error('mark-paid: invoice update failed', updateErr as Error, { + invoiceId, + companyId: ctx.companyId, + pgCode: (updateErr as { code?: string }).code, + }) + return v1ErrorResponseFromCode('INVOICE_PAID_BOOK_FAILED', ctx.log, { + requestId: ctx.requestId, + }) + } + if (!updated) { + // Race: status transitioned (concurrent mark-paid / credit) between + // pre-flight and our update. Surface as 409. + ctx.log.warn('mark-paid: race — invoice status transitioned during request', { + invoiceId, + companyId: ctx.companyId, + }) + return v1ErrorResponseFromCode('INVOICE_PAID_RACE', ctx.log, { + requestId: ctx.requestId, + }) + } + + // Step 3: emit invoice.paid (best-effort, surfaces in warnings on fail). + try { + await eventBus.emit({ + type: 'invoice.paid', + payload: { + invoice: updated as unknown as Invoice, + companyId: ctx.companyId!, + userId: ctx.userId, + paymentAmount, + paymentDate, + }, + }) + } catch (err) { + ctx.log.error('invoice.paid emit failed', err as Error, { + invoiceId, + companyId: ctx.companyId, + }) + warnings.push({ + code: 'EVENT_EMIT_FAILED', + message: 'invoice.paid event did not reach the bus; downstream subscribers may miss this transition.', + }) + } + + ctx.log.info('invoices.mark-paid success', { + invoiceId, + companyId: ctx.companyId, + userId: ctx.userId, + newStatus, + journalEntryId, + paymentAmount, + isPartial, + hadWarnings: warnings.length > 0, + }) + + return ok( + { + ...(updated as object), + journal_entry_id: journalEntryId, + ...(warnings.length > 0 ? { warnings } : {}), + }, + { requestId: ctx.requestId }, + ) + }, + { requireIdempotencyKey: true }, +) diff --git a/lib/api/v1/load-routes.ts b/lib/api/v1/load-routes.ts index aa6419c0..ab2aa9e9 100644 --- a/lib/api/v1/load-routes.ts +++ b/lib/api/v1/load-routes.ts @@ -22,5 +22,7 @@ import '@/app/api/v1/companies/[companyId]/customers/route' import '@/app/api/v1/companies/[companyId]/customers/[id]/route' // Phase 2 PR-B-2b — invoice action verbs. import '@/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route' +import '@/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route' +import '@/app/api/v1/companies/[companyId]/invoices/[id]/credit/route' export {} diff --git a/lib/api/v1/with-api-v1.ts b/lib/api/v1/with-api-v1.ts index 2c3eab21..4db91fe9 100644 --- a/lib/api/v1/with-api-v1.ts +++ b/lib/api/v1/with-api-v1.ts @@ -33,6 +33,7 @@ import { createClient, type SupabaseClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' import { type ApiKeyMode, type ApiKeyScope, @@ -41,6 +42,14 @@ import { hasScope, validateApiKey, } from '@/lib/auth/api-keys' + +// Per CLAUDE.md: any route that emits events via eventBus must call +// ensureInitialized() at module level to wire extension event handlers +// (email, cloud-backup, push-notifications, etc.). Calling it here in the +// wrapper guarantees every v1 route gets the init at import time — a single +// source of truth so future routes can't forget. The function itself is +// idempotent (guarded by a module-level boolean). +ensureInitialized() import { resolveRequiredScope } from '@/lib/auth/scopes' import { checkIdempotencyKey, diff --git a/lib/auth/scopes.ts b/lib/auth/scopes.ts index f8fd7a95..46baddc9 100644 --- a/lib/auth/scopes.ts +++ b/lib/auth/scopes.ts @@ -61,6 +61,8 @@ export const V1_ENDPOINT_SCOPES: Record = { // Phase 2 PR-B-2b — action verbs. URL uses /verb subpath (not Google-AIP-style :verb) // because Next.js routes don't support `:` in folder names. 'POST /api/v1/companies/:companyId/invoices/:id/mark-sent': 'invoices:write', + 'POST /api/v1/companies/:companyId/invoices/:id/mark-paid': 'invoices:write', + 'POST /api/v1/companies/:companyId/invoices/:id/credit': 'invoices:write', // Webhooks (Phase 6 — placeholder so the catalogue is complete) 'GET /api/v1/companies/:companyId/webhooks': 'webhooks:manage', diff --git a/lib/events/types.ts b/lib/events/types.ts index c85c6838..59b338c3 100644 --- a/lib/events/types.ts +++ b/lib/events/types.ts @@ -30,6 +30,7 @@ export type CoreEvent = // Invoicing | { type: 'invoice.created'; payload: { invoice: Invoice; userId: string; companyId: string } } | { type: 'invoice.sent'; payload: { invoice: Invoice; userId: string; companyId: string } } + | { type: 'invoice.paid'; payload: { invoice: Invoice; paymentAmount: number; paymentDate: string; userId: string; companyId: string } } | { type: 'credit_note.created'; payload: { creditNote: CreditNote; userId: string; companyId: string } } // Banking | { type: 'transaction.synced'; payload: { transactions: Transaction[]; userId: string; companyId: string } }