diff --git a/app/(dashboard)/bookkeeping/[id]/page.tsx b/app/(dashboard)/bookkeeping/[id]/page.tsx index c973d018..99fe0a60 100644 --- a/app/(dashboard)/bookkeeping/[id]/page.tsx +++ b/app/(dashboard)/bookkeeping/[id]/page.tsx @@ -21,6 +21,7 @@ import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments' import JournalEntryStatusBadge, { useSourceTypeLabels } from '@/components/bookkeeping/JournalEntryStatusBadge' import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog' +import EditDraftEntryDialog from '@/components/bookkeeping/EditDraftEntryDialog' import RecordateEntryDialog from '@/components/bookkeeping/RecordateEntryDialog' import CorrectionChain from '@/components/bookkeeping/CorrectionChain' import { ConfirmationDialog } from '@/components/ui/confirmation-dialog' @@ -41,6 +42,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) const [showCorrection, setShowCorrection] = useState(false) + const [showEdit, setShowEdit] = useState(false) const [showRecordate, setShowRecordate] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [isDeleting, setIsDeleting] = useState(false) @@ -233,6 +235,19 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i {(entry.status === 'posted' || entry.status === 'draft') && (
+ {entry.status === 'draft' && ( + + )} {entry.status === 'draft' && (
)} - {/* 3. Banktransaktioner */} + {/* 3. Banktransaktioner — manual file imports (bank file, CSV/Excel, + SIE) run entirely on uploaded data with no external service, so + they stay available in the sandbox, unlike the API-backed options + above (bank connection, provider migration) which need live + credentials. */}
{ if (!isSandbox) setMode('bank') }} - onKeyDown={(e) => { if (!isSandbox && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); setMode('bank') } }} + onClick={() => setMode('bank')} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setMode('bank') } }} >
@@ -2299,16 +2305,13 @@ export default function ImportPage() { {/* 4. CSV/Excel-data (ingående balanser, kunder, leverantörer) */}
{ if (!isSandbox) setMode('csv_data') }} - onKeyDown={(e) => { if (!isSandbox && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); setMode('csv_data') } }} + onClick={() => setMode('csv_data')} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setMode('csv_data') } }} >
@@ -2339,16 +2342,13 @@ export default function ImportPage() { {/* 5. Bokföringsdata (SIE) */}
{ if (!isSandbox) setMode('sie') }} - onKeyDown={(e) => { if (!isSandbox && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); setMode('sie') } }} + onClick={() => setMode('sie')} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setMode('sie') } }} >
@@ -2415,7 +2415,7 @@ export default function ImportPage() { window.open(`/api/reports/sie-export?${params.toString()}`, '_blank') } }} - disabled={!exportPeriodId || isSandbox} + disabled={!exportPeriodId} className="w-full sm:w-auto" > diff --git a/app/api/bookkeeping/journal-entries/[id]/route.ts b/app/api/bookkeeping/journal-entries/[id]/route.ts index 99b2658e..f08e41f6 100644 --- a/app/api/bookkeeping/journal-entries/[id]/route.ts +++ b/app/api/bookkeeping/journal-entries/[id]/route.ts @@ -7,6 +7,11 @@ import { eventBus } from '@/lib/events/bus' import { getErrorMessage } from '@/lib/errors/get-error-message' import { createLogger } from '@/lib/logger' import { syncInvoiceStatusFromPaymentEntry } from '@/lib/bookkeeping/payment-sync' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateBody } from '@/lib/api/validate' +import { CreateJournalEntrySchema } from '@/lib/api/schemas' +import { updateDraftEntry } from '@/lib/bookkeeping/engine' +import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors' const logger = createLogger('journal-entries') @@ -103,3 +108,31 @@ export async function DELETE( return NextResponse.json({ data }) } + +/** + * PATCH — edit a DRAFT verifikat in place (header + lines). Only drafts are + * editable; updateDraftEntry rejects committed entries with a 409, and the DB + * immutability trigger is the backstop. Uses withRouteContext (MFA + write gate) + * — the GET/DELETE above predate that wrapper and are intentionally left as-is. + */ +export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>( + 'bookkeeping.journal_entry.update', + async (request, { supabase, companyId, user }, { params }) => { + const { id } = await params + const validation = await validateBody(request, CreateJournalEntrySchema) + if (!validation.success) return validation.response + + try { + const entry = await updateDraftEntry(supabase, companyId, user.id, id, validation.data) + return NextResponse.json({ data: entry }) + } catch (err) { + const typed = bookkeepingErrorResponse(err) + if (typed) return typed + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to update journal entry' }, + { status: 400 }, + ) + } + }, + { requireWrite: true }, +) diff --git a/app/api/bookkeeping/journal-entries/__tests__/list-filters.pg.test.ts b/app/api/bookkeeping/journal-entries/__tests__/list-filters.pg.test.ts new file mode 100644 index 00000000..504b872a --- /dev/null +++ b/app/api/bookkeeping/journal-entries/__tests__/list-filters.pg.test.ts @@ -0,0 +1,100 @@ +import { randomUUID } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { getPool } from '@/tests/pg/setup' +import { seedCompany, insertBalancedLines } from '@/tests/pg/fixtures' + +// Covers the p_exclude_draft / p_collapse_corrections params added to +// list_fiscal_period_entries_with_related (migration 20260621130500). +// - exclude_draft: drafts kept off the committed list (own "Utkast" surface). +// - collapse_corrections: a correction group renders as ONE row — the live +// correction; the storno and the reversed original it replaced are hidden. +// total_count must stay in lockstep with the filtered set so pagination holds. +describe('list_fiscal_period_entries_with_related: draft + correction filters', () => { + // Insert a journal_entry directly so we can set the storno/correction link + // columns the fixtures don't expose. Posted/reversed rows get balanced lines + // so any deferred balance check is satisfied. + async function insertEntry(p: { + userId: string + companyId: string + fiscalPeriodId: string + status: 'draft' | 'posted' | 'reversed' + sourceType: string + voucherNumber: number + description: string + reversesId?: string + correctionOfId?: string + withLines?: boolean + }): Promise { + const id = randomUUID() + await getPool().query( + `INSERT INTO public.journal_entries + (id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series, + entry_date, description, source_type, status, reverses_id, correction_of_id) + VALUES ($1,$2,$3,$4,$5,'A','2026-06-01',$6,$7,$8,$9,$10)`, + [ + id, + p.userId, + p.companyId, + p.fiscalPeriodId, + p.voucherNumber, + p.description, + p.sourceType, + p.status, + p.reversesId ?? null, + p.correctionOfId ?? null, + ], + ) + if (p.withLines) await insertBalancedLines(id) + return id + } + + async function callRpc( + companyId: string, + periodId: string, + opts: { status?: string | null; excludeDraft?: boolean; collapse?: boolean } = {}, + ) { + const { rows } = await getPool().query<{ entry: { id: string }; total_count: string }>( + `SELECT entry, total_count + FROM list_fiscal_period_entries_with_related( + $1, $2, true, $3, NULL, NULL, 'desc', 100, 0, $4, $5)`, + [companyId, periodId, opts.status ?? null, opts.excludeDraft ?? false, opts.collapse ?? false], + ) + return rows + } + + it('excludes drafts and collapses a correction group to the live correction', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + + const posted = await insertEntry({ userId, companyId, fiscalPeriodId, status: 'posted', sourceType: 'manual', voucherNumber: 10, withLines: true, description: 'Plain posted' }) + const draft = await insertEntry({ userId, companyId, fiscalPeriodId, status: 'draft', sourceType: 'manual', voucherNumber: 0, description: 'Draft' }) + // Correction group: original is reversed; storno reverses it; correction replaces it. + const original = await insertEntry({ userId, companyId, fiscalPeriodId, status: 'reversed', sourceType: 'manual', voucherNumber: 11, withLines: true, description: 'Original' }) + const storno = await insertEntry({ userId, companyId, fiscalPeriodId, status: 'posted', sourceType: 'storno', voucherNumber: 12, reversesId: original, withLines: true, description: 'Storno' }) + const correction = await insertEntry({ userId, companyId, fiscalPeriodId, status: 'posted', sourceType: 'correction', voucherNumber: 13, correctionOfId: original, withLines: true, description: 'Correction' }) + + // Default (no filters): every row shows. + const all = await callRpc(companyId, fiscalPeriodId, {}) + const allIds = all.map((r) => r.entry.id) + expect(allIds).toEqual(expect.arrayContaining([posted, draft, original, storno, correction])) + expect(Number(all[0]!.total_count)).toBe(5) + + // Committed list: drafts, stornos and reversed-corrected originals hidden. + const filtered = await callRpc(companyId, fiscalPeriodId, { excludeDraft: true, collapse: true }) + const ids = filtered.map((r) => r.entry.id) + expect(ids).toEqual(expect.arrayContaining([posted, correction])) + expect(ids).not.toContain(draft) + expect(ids).not.toContain(storno) + expect(ids).not.toContain(original) + expect(Number(filtered[0]!.total_count)).toBe(2) + }) + + it('still returns drafts when status=draft is requested explicitly', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + await insertEntry({ userId, companyId, fiscalPeriodId, status: 'posted', sourceType: 'manual', voucherNumber: 10, withLines: true, description: 'Posted' }) + const draft = await insertEntry({ userId, companyId, fiscalPeriodId, status: 'draft', sourceType: 'manual', voucherNumber: 0, description: 'Draft' }) + + // Drafts mode (status=draft). exclude_draft must NOT cancel the explicit ask. + const rows = await callRpc(companyId, fiscalPeriodId, { status: 'draft', excludeDraft: true }) + expect(rows.map((r) => r.entry.id)).toEqual([draft]) + }) +}) diff --git a/app/api/bookkeeping/journal-entries/route.ts b/app/api/bookkeeping/journal-entries/route.ts index 5d776bd1..a0295fa8 100644 --- a/app/api/bookkeeping/journal-entries/route.ts +++ b/app/api/bookkeeping/journal-entries/route.ts @@ -24,6 +24,11 @@ export async function GET(request: Request) { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') const status = searchParams.get('status') + // Drafts get their own surface in the UI; the committed list excludes them. + const excludeDraft = searchParams.get('exclude_draft') === 'true' + // Collapse a correction group to the live correction (hide the storno and the + // reversed original it replaced). The full chain stays reachable. + const collapseCorrections = searchParams.get('collapse_corrections') === 'true' // Clamp pagination to bound DB work against oversized/pathological inputs // (compliance A.8.28 / ASVS V1.2.5). The UI page-size selector offers // 20/50/100/Alla; "Alla" sends a large limit which is capped at MAX_LIMIT. @@ -81,6 +86,8 @@ export async function GET(request: Request) { p_sort_date: sortDateParam, p_limit: limit, p_offset: offset, + p_exclude_draft: excludeDraft, + p_collapse_corrections: collapseCorrections, }) if (error) { @@ -134,6 +141,9 @@ export async function GET(request: Request) { query = query.eq('status', status) } else { query = query.neq('status', 'cancelled') + if (excludeDraft) { + query = query.neq('status', 'draft') + } } if (dateFrom) { @@ -157,6 +167,26 @@ export async function GET(request: Request) { query = query.ilike('description', `%${escapeLikePattern(search)}%`) } + // Collapse correction groups (voucher-sort / search path): hide the storno + // and the reversed originals a posted correction replaced, leaving the live + // correction. Pagination/count stay correct because these are query filters. + if (collapseCorrections) { + query = query.neq('source_type', 'storno') + const { data: corrections } = await supabase + .from('journal_entries') + .select('correction_of_id') + .eq('company_id', companyId) + .eq('source_type', 'correction') + .eq('status', 'posted') + .not('correction_of_id', 'is', null) + const correctedOriginalIds = Array.from( + new Set((corrections ?? []).map((r) => r.correction_of_id).filter(Boolean) as string[]) + ) + if (correctedOriginalIds.length > 0) { + query = query.not('id', 'in', `(${correctedOriginalIds.join(',')})`) + } + } + const { data, error, count } = await query if (error) { diff --git a/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts b/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts index 6cd47091..b2ae184f 100644 --- a/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts +++ b/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts @@ -40,6 +40,7 @@ vi.mock('@react-pdf/renderer', () => ({ vi.mock('@/lib/invoices/pdf-template', () => ({ InvoicePDF: vi.fn().mockReturnValue('mock-pdf-element'), brandingFromCompanySettings: vi.fn().mockReturnValue({}), + SHOW_SWISH_ON_INVOICE: false, })) import { InvoicePDF } from '@/lib/invoices/pdf-template' diff --git a/app/api/invoices/[id]/mark-sent/route.ts b/app/api/invoices/[id]/mark-sent/route.ts index 3091bba7..c7c455c8 100644 --- a/app/api/invoices/[id]/mark-sent/route.ts +++ b/app/api/invoices/[id]/mark-sent/route.ts @@ -6,7 +6,7 @@ import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/fr import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number' import { ensureInitialized } from '@/lib/init' import { InvoicePDF } from '@/lib/invoices/pdf-template' -import { prepareInvoicePdfRender } from '@/lib/invoices/pdf-render-helpers' +import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers' import { uploadDocument } from '@/lib/core/documents/document-service' import { requireCompanyId } from '@/lib/company/context' import { requireWritePermission } from '@/lib/auth/require-write' @@ -170,6 +170,7 @@ export async function POST( // underlag isn't stamped "UTKAST – inte en giltig faktura". const renderableInvoice = { ...(invoice as Invoice), status: 'sent' as const } const { branding } = prepareInvoicePdfRender(settings as CompanySettings) + const swishQrDataUrl = await buildSwishQrDataUrl(settings as CompanySettings, renderableInvoice) const pdfBuffer = await renderToBuffer( InvoicePDF({ invoice: renderableInvoice, @@ -178,6 +179,7 @@ export async function POST( company: settings as CompanySettings, originalInvoiceNumber, branding, + swishQrDataUrl, }) ) diff --git a/app/api/invoices/[id]/pdf/route.ts b/app/api/invoices/[id]/pdf/route.ts index 9befa4d2..1ea47d7c 100644 --- a/app/api/invoices/[id]/pdf/route.ts +++ b/app/api/invoices/[id]/pdf/route.ts @@ -2,7 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { renderToBuffer } from '@react-pdf/renderer' import { InvoicePDF } from '@/lib/invoices/pdf-template' -import { prepareInvoicePdfRender } from '@/lib/invoices/pdf-render-helpers' +import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers' import { requireCompanyId } from '@/lib/company/context' import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types' @@ -68,6 +68,7 @@ export async function GET( try { // Generate PDF const { branding } = prepareInvoicePdfRender(company as CompanySettings) + const swishQrDataUrl = await buildSwishQrDataUrl(company as CompanySettings, invoice as Invoice) const pdfBuffer = await renderToBuffer( InvoicePDF({ invoice: invoice as Invoice, @@ -76,6 +77,7 @@ export async function GET( company: company as CompanySettings, originalInvoiceNumber, branding, + swishQrDataUrl, }) ) diff --git a/app/api/invoices/[id]/send/__tests__/route.test.ts b/app/api/invoices/[id]/send/__tests__/route.test.ts index e84131d4..62401062 100644 --- a/app/api/invoices/[id]/send/__tests__/route.test.ts +++ b/app/api/invoices/[id]/send/__tests__/route.test.ts @@ -41,6 +41,7 @@ vi.mock('@react-pdf/renderer', () => ({ vi.mock('@/lib/invoices/pdf-template', () => ({ InvoicePDF: vi.fn().mockReturnValue('mock-pdf-element'), brandingFromCompanySettings: vi.fn().mockReturnValue({}), + SHOW_SWISH_ON_INVOICE: false, })) import { InvoicePDF } from '@/lib/invoices/pdf-template' diff --git a/app/api/invoices/[id]/send/route.ts b/app/api/invoices/[id]/send/route.ts index 3ee37383..fdc2afdc 100644 --- a/app/api/invoices/[id]/send/route.ts +++ b/app/api/invoices/[id]/send/route.ts @@ -3,7 +3,7 @@ import { eventBus } from '@/lib/events' import { ensureInitialized } from '@/lib/init' import { renderToBuffer } from '@react-pdf/renderer' import { InvoicePDF } from '@/lib/invoices/pdf-template' -import { prepareInvoicePdfRender } from '@/lib/invoices/pdf-render-helpers' +import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers' import { getEmailService } from '@/lib/email/service' import { generateInvoiceEmailHtml, @@ -133,6 +133,7 @@ export const POST = withRouteContext( // receives a PDF stamped "UTKAST – inte en giltig faktura". const renderableInvoice = { ...(invoice as Invoice), status: 'sent' as const } const { branding } = prepareInvoicePdfRender(company as CompanySettings) + const swishQrDataUrl = await buildSwishQrDataUrl(company as CompanySettings, renderableInvoice) const pdfBuffer = await renderToBuffer( InvoicePDF({ invoice: renderableInvoice, @@ -141,6 +142,7 @@ export const POST = withRouteContext( company: company as CompanySettings, originalInvoiceNumber, branding, + swishQrDataUrl, }), ) diff --git a/app/api/settings/api-keys/__tests__/route.test.ts b/app/api/settings/api-keys/__tests__/route.test.ts index 14cdf1cb..ed7a0715 100644 --- a/app/api/settings/api-keys/__tests__/route.test.ts +++ b/app/api/settings/api-keys/__tests__/route.test.ts @@ -189,5 +189,40 @@ describe('POST /api/settings/api-keys', () => { expect(payload).not.toHaveProperty('sod_acknowledged_at') expect(payload).not.toHaveProperty('sod_acknowledged_by') expect(payload.scopes).toEqual(['reports:read']) + // Default mode is live, bound to the active company. + expect(payload.mode).toBe('live') + expect(payload.company_id).toBe('company-1') + }) + + it('creates a test key bound to the active company with mode=test', async () => { + const { insertSpy } = setupFrom({ + count: 0, + insertResult: { + data: { + id: 'ak-3', + key_prefix: 'gnubok_sk_test_abc', + name: 'pilot', + scopes: ['reports:read'], + mode: 'test', + created_at: '2026-06-05T10:00:00Z', + }, + }, + }) + const res = await POST( + createMockRequest('/api/settings/api-keys', { + method: 'POST', + body: { name: 'pilot', scopes: ['reports:read'], mode: 'test' }, + }), + ) + const { status, body } = await parseJsonResponse<{ data: { key: string } }>(res) + expect(status).toBe(200) + // Real generateApiKey('test') runs — the returned secret carries the infix. + expect(body.data.key).toMatch(/^gnubok_sk_test_/) + + const payload = insertSpy.mock.calls[0][0] as Record + expect(payload.mode).toBe('test') + // Test keys are simulation-only — they bind to the active company (the v1 + // wrapper forces dry-run so they never persist). + expect(payload.company_id).toBe('company-1') }) }) diff --git a/app/api/settings/api-keys/route.ts b/app/api/settings/api-keys/route.ts index 94cc392b..1b926e22 100644 --- a/app/api/settings/api-keys/route.ts +++ b/app/api/settings/api-keys/route.ts @@ -7,7 +7,7 @@ import { } from '@/lib/auth/api-keys' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' -import type { ApiKeyScope } from '@/lib/auth/api-keys' +import type { ApiKeyMode, ApiKeyScope } from '@/lib/auth/api-keys' /** GET /api/settings/api-keys — list the company's API keys (key value never returned). */ export const GET = withRouteContext( @@ -15,9 +15,11 @@ export const GET = withRouteContext( async (_request, ctx) => { const { supabase, companyId, log, requestId } = ctx + // Both live and test keys for the active company. (Test keys are bound to the + // active company too — they're simulation-only, so they never write real data.) const { data, error } = await supabase .from('api_keys') - .select('id, key_prefix, name, scopes, rate_limit_rpm, last_used_at, revoked_at, created_at') + .select('id, key_prefix, name, scopes, mode, rate_limit_rpm, last_used_at, revoked_at, created_at') .eq('company_id', companyId) .order('created_at', { ascending: false }) @@ -44,12 +46,14 @@ export const POST = withRouteContext( let name = 'Unnamed key' let scopes: ApiKeyScope[] = DEFAULT_SCOPES let acknowledgeSod = false + let mode: ApiKeyMode = 'live' try { const body = await request.json() if (body.name && typeof body.name === 'string') { name = body.name.slice(0, 100) } acknowledgeSod = body.acknowledge_sod === true + if (body.mode === 'test') mode = 'test' const parsed = validateScopes(body.scopes) if (parsed) { scopes = parsed @@ -63,6 +67,10 @@ export const POST = withRouteContext( // Empty body — use defaults. } + // Both live and test keys bind to the active company. A test key is + // simulation-only — the v1 wrapper forces dry-run on every write — so it can + // safely point at the real company without ever persisting anything. + // Segregation of duties: warn + require explicit acknowledgement (not block) // when a single key both stages bookkeeping AND can approve it. Surfacing a // 409 lets the UI raise an explicit confirm dialog and the agent inform the @@ -92,7 +100,7 @@ export const POST = withRouteContext( }) } - const { key, hash, prefix } = generateApiKey() + const { key, hash, prefix } = generateApiKey(mode) const { data, error } = await supabase .from('api_keys') @@ -103,11 +111,12 @@ export const POST = withRouteContext( key_prefix: prefix, name, scopes, + mode, ...(sodAcknowledgedAt ? { sod_acknowledged_at: sodAcknowledgedAt, sod_acknowledged_by: user.id } : {}), }) - .select('id, key_prefix, name, scopes, created_at') + .select('id, key_prefix, name, scopes, mode, created_at') .single() if (error) { diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/pdf/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/[id]/pdf/__tests__/route.test.ts index d402d924..37d9348a 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/pdf/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/pdf/__tests__/route.test.ts @@ -40,6 +40,7 @@ vi.mock('@react-pdf/renderer', () => ({ vi.mock('@/lib/invoices/pdf-template', () => ({ InvoicePDF: vi.fn().mockReturnValue({}), brandingFromCompanySettings: vi.fn().mockReturnValue({}), + SHOW_SWISH_ON_INVOICE: false, })) import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/pdf/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/pdf/route.ts index 19a165e7..b13bbfc9 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/pdf/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/pdf/route.ts @@ -20,7 +20,7 @@ import { z } from 'zod' import { renderToBuffer } from '@react-pdf/renderer' import { InvoicePDF } from '@/lib/invoices/pdf-template' -import { prepareInvoicePdfRender } from '@/lib/invoices/pdf-render-helpers' +import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers' import { registerEndpoint } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' @@ -150,6 +150,7 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string } let pdfBuffer: Buffer try { const { branding } = prepareInvoicePdfRender(company as CompanySettings) + const swishQrDataUrl = await buildSwishQrDataUrl(company as CompanySettings, typed as Invoice) pdfBuffer = await renderToBuffer( InvoicePDF({ invoice: typed as Invoice, @@ -158,6 +159,7 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string } company: company as CompanySettings, originalInvoiceNumber, branding, + swishQrDataUrl, }), ) } catch (err) { diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts index d3c7857b..c20c3c56 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts @@ -72,6 +72,7 @@ vi.mock('@/lib/email/invoice-templates', () => ({ vi.mock('@/lib/invoices/pdf-template', () => ({ InvoicePDF: vi.fn().mockReturnValue({}), brandingFromCompanySettings: vi.fn().mockReturnValue({}), + SHOW_SWISH_ON_INVOICE: false, })) // The sandbox guard reads company_settings.is_sandbox at the top of the @@ -411,6 +412,40 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/send', () => { expect(finalRenderArgs.invoice.invoice_number).toBe('2026-0043') }) + it('test-mode key forces dry-run: returns a preview, no email, no number burned', async () => { + // A test key has no ?dry_run flag, but the wrapper forces dry-run because + // the key is mode='test'. The send endpoint declares dryRunSupported, so the + // request is allowed and short-circuits to the preview. + mockValidate.mockResolvedValue({ + userId: USER_ID, + companyId: COMPANY_ID, + apiKeyId: 'ak_test', + apiKeyName: 'Test key', + scopes: ['invoices:write'], + mode: 'test', + }) + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: DRAFT_INVOICE, error: null }, + company_settings: { data: COMPANY_SETTINGS, error: null }, + }), + ) + + const res = await sendInvoice( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(200) + expect(res.headers.get('X-Gnubok-Mode')).toBe('test') + const body = await res.json() + expect(body.data.dry_run).toBe(true) + expect(body.data.preview.status).toBe('sent') + expect(body.data.preview.would_send_to).toBe('billing@acme.test') + expect(mockSendEmail).not.toHaveBeenCalled() + }) + it('rejects keys without invoices:write scope', async () => { mockValidate.mockResolvedValue({ userId: USER_ID, diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts index 33380c66..6182c375 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts @@ -43,7 +43,7 @@ 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 { InvoicePDF } from '@/lib/invoices/pdf-template' -import { prepareInvoicePdfRender } from '@/lib/invoices/pdf-render-helpers' +import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers' import { getEmailService } from '@/lib/email/service' import { generateInvoiceEmailHtml, @@ -362,6 +362,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string let pdfBuffer: Buffer try { const { branding } = prepareInvoicePdfRender(settings) + const swishQrDataUrl = await buildSwishQrDataUrl(settings, renderableInvoice) pdfBuffer = await renderToBuffer( InvoicePDF({ invoice: renderableInvoice, @@ -370,6 +371,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string company: settings, originalInvoiceNumber, branding, + swishQrDataUrl, }), ) } catch (err) { diff --git a/app/llms.txt/route.ts b/app/llms.txt/route.ts index c31ba784..7e447c65 100644 --- a/app/llms.txt/route.ts +++ b/app/llms.txt/route.ts @@ -37,8 +37,13 @@ declarations, ingest SIE files, and subscribe to webhooks for state changes. account deltas) without committing. The same call without dry-run commits. - **Idempotency-Key on every write.** Pass a UUID in \`Idempotency-Key\`; replays return the cached response (24h TTL) with \`Idempotent-Replayed: true\`. -- **Test mode.** API keys prefixed \`gnubok_sk_test_\` are bound to deterministic - sandbox companies — safe for evals and agent learning. Live keys hit real data. +- **Test mode.** Create a key with mode \`test\` (prefix \`gnubok_sk_test_\`) in the + dashboard. A test key forces \`dry_run\` on every write against your real company — + you get a realistic 200 + preview of what *would* happen, but nothing is ever + saved or sent. Reads return real data; responses carry \`X-Gnubok-Mode: test\`. + Writes on endpoints that can't be simulated are refused (403 + \`TEST_KEY_WRITE_BLOCKED\`). It's \`?dry_run=true\` baked into the credential, so + you can develop safely before switching to a live key. - **Compliance pre-flight.** \`GET /api/v1/companies/{id}/compliance/check?type=…\` returns structured findings (voucher gaps, locked-period violations, VAT close blockers, missing receipts) before you submit. diff --git a/components/articles/ArticleForm.tsx b/components/articles/ArticleForm.tsx index 86e89884..ff72e9d5 100644 --- a/components/articles/ArticleForm.tsx +++ b/components/articles/ArticleForm.tsx @@ -13,6 +13,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { ChevronDown, Loader2, Lock } from 'lucide-react' import { cn } from '@/lib/utils' import { useCanWrite } from '@/lib/hooks/use-can-write' +import { useCompany } from '@/contexts/CompanyContext' +import { createClient } from '@/lib/supabase/client' import AccountCombobox from '@/components/bookkeeping/AccountCombobox' import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog' import type { BASAccount, CreateArticleInput } from '@/types' @@ -36,6 +38,8 @@ export default function ArticleForm({ initialData, }: ArticleFormProps) { const { canWrite } = useCanWrite() + const { company } = useCompany() + const supabase = createClient() const t = useTranslations('form_article') // Active class-3 (revenue) accounts for the combobox. The combobox accepts // unknown 4-digit numbers optimistically — the API answers with @@ -45,6 +49,9 @@ export default function ArticleForm({ // Inline account creation: what the user typed in the combobox when they hit // "Skapa konto" — non-null opens AddAccountDialog prefilled with it. const [createAccountPrefill, setCreateAccountPrefill] = useState(null) + // Momsregistrerad? A non-VAT-registered company never charges moms, so the + // VAT field is hidden and the rate forced to 0 — mirrors the invoice editor. + const [vatRegistered, setVatRegistered] = useState(true) async function fetchRevenueAccounts() { try { @@ -59,6 +66,25 @@ export default function ArticleForm({ useEffect(() => { fetchRevenueAccounts() }, []) + + useEffect(() => { + if (!company?.id) return + let cancelled = false + supabase + .from('company_settings') + .select('vat_registered') + .eq('company_id', company.id) + .single() + .then(({ data }) => { + if (!cancelled && typeof data?.vat_registered === 'boolean') { + setVatRegistered(data.vat_registered) + } + }) + return () => { + cancelled = true + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [company?.id]) // Open the advanced section by default when it already holds data, so an // edit never hides a value the user previously set. const [advancedOpen, setAdvancedOpen] = useState( @@ -125,7 +151,7 @@ export default function ArticleForm({ type: data.type, unit: data.unit, price_excl_vat: data.price_excl_vat, - vat_rate: data.vat_rate, + vat_rate: vatRegistered ? data.vat_rate : 0, revenue_account: data.revenue_account || null, cost_price: data.cost_price ?? null, ean: data.ean || null, @@ -180,8 +206,8 @@ export default function ArticleForm({

{t('name_en_hint')}

- {/* Unit + price + VAT */} -
+ {/* Unit + price + VAT (moms hidden for non-momsregistrerade) */} +
{errors.price_excl_vat.message}

)}
+ {vatRegistered && (
+ )}
{/* Advanced (collapsible) */} diff --git a/components/bookkeeping/EditDraftEntryDialog.tsx b/components/bookkeeping/EditDraftEntryDialog.tsx new file mode 100644 index 00000000..55f18a4d --- /dev/null +++ b/components/bookkeeping/EditDraftEntryDialog.tsx @@ -0,0 +1,66 @@ +'use client' + +import { useTranslations } from 'next-intl' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import JournalEntryForm, { type FormLine } from '@/components/bookkeeping/JournalEntryForm' +import type { JournalEntry, JournalEntryLine } from '@/types' + +interface Props { + entry: JournalEntry + open: boolean + onOpenChange: (open: boolean) => void + /** Fired after the draft is successfully updated. */ + onUpdated: () => void +} + +/** + * Edit a DRAFT verifikat. Wraps JournalEntryForm in edit mode, pre-filled from + * the draft's header + lines; the form PATCHes the entry in place and it stays + * a draft (the user posts it separately). Only ever opened for status==='draft' + * entries — the engine + DB triggers reject edits on committed entries anyway. + */ +export default function EditDraftEntryDialog({ entry, open, onOpenChange, onUpdated }: Props) { + const t = useTranslations('bookkeeping') + + const initialLines: FormLine[] = ((entry.lines || []) as JournalEntryLine[]) + .slice() + .sort((a, b) => a.sort_order - b.sort_order) + .map((l) => ({ + account_number: l.account_number, + debit_amount: Number(l.debit_amount) > 0 ? String(l.debit_amount) : '', + credit_amount: Number(l.credit_amount) > 0 ? String(l.credit_amount) : '', + line_description: l.line_description || '', + })) + + return ( + + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + > + + {t('edit_draft_dialog_title')} + + + + + ) +} diff --git a/components/bookkeeping/JournalEntryForm.tsx b/components/bookkeeping/JournalEntryForm.tsx index b53b91fd..2f6e4539 100644 --- a/components/bookkeeping/JournalEntryForm.tsx +++ b/components/bookkeeping/JournalEntryForm.tsx @@ -59,6 +59,7 @@ interface Props { initialDate?: string initialDescription?: string initialNotes?: string + initialVoucherSeries?: string sourceType?: JournalEntrySourceType sourceId?: string submitUrl?: string @@ -66,6 +67,11 @@ interface Props { /** Render without the Card chrome (e.g. inside a dialog) but keep the full * non-embedded field set (series, notes, documents, voucher hint). */ bare?: boolean + /** Edit an existing DRAFT in place: the form PATCHes this entry instead of + * creating a new one. Only the draft's header + lines are updated. */ + editEntryId?: string + /** Fired after a successful draft edit (editEntryId path). */ + onUpdated?: () => void } const BLANK_LINE: FormLine = { account_number: '', debit_amount: '', credit_amount: '', line_description: '' } @@ -77,11 +83,14 @@ export default function JournalEntryForm({ initialDate, initialDescription, initialNotes, + initialVoucherSeries, sourceType, sourceId, submitUrl, embedded, bare, + editEntryId, + onUpdated, }: Props) { const { canWrite } = useCanWrite() const { toast } = useToast() @@ -97,7 +106,7 @@ export default function JournalEntryForm({ const [lines, setLines] = useState( initialLines ?? [{ ...BLANK_LINE }, { ...BLANK_LINE }] ) - const [voucherSeries, setVoucherSeries] = useState('A') + const [voucherSeries, setVoucherSeries] = useState(initialVoucherSeries ?? 'A') const [nextVoucherNumber, setNextVoucherNumber] = useState(null) const [isSubmitting, setIsSubmitting] = useState(false) const [showReview, setShowReview] = useState(false) @@ -170,7 +179,9 @@ export default function JournalEntryForm({ // Fetch default voucher series from company settings — prefer the // per-source-type mapping when present; fall back to the legacy // default_voucher_series, then to 'A'. - if (!embedded) { + // In edit mode the draft's own series is pre-filled — never override it + // from the company defaults. + if (!embedded && !editEntryId) { fetch('/api/settings').then(r => r.json()).then(({ data }) => { if (!data) return const effectiveSourceType = sourceType ?? 'manual' @@ -182,7 +193,7 @@ export default function JournalEntryForm({ setVoucherSeries(perSource !== 'A' ? perSource : fallback) }).catch(() => {/* keep 'A' */}) } - }, [embedded, sourceType]) + }, [embedded, sourceType, editEntryId]) // Auto-select period when entry date changes useEffect(() => { @@ -539,9 +550,15 @@ export default function JournalEntryForm({ }) const baseUrl = submitUrl ?? '/api/bookkeeping/journal-entries' - const url = saveAsDraftRef.current ? `${baseUrl}?as_draft=true` : baseUrl + // Edit mode PATCHes the draft in place; create mode POSTs (with ?as_draft + // when saving a draft rather than posting). + const url = editEntryId + ? `${baseUrl}/${editEntryId}` + : saveAsDraftRef.current + ? `${baseUrl}?as_draft=true` + : baseUrl const res = await fetch(url, { - method: 'POST', + method: editEntryId ? 'PATCH' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fiscal_period_id: selectedPeriod, @@ -555,7 +572,7 @@ export default function JournalEntryForm({ }), }) return (await throwOnStructuredError(res)) as { data?: { id?: string; voucher_series?: string; voucher_number?: number }; journal_entry_id?: string } - }, [lines, isForeign, rate, entryCurrency, computedForeignAmount, submitUrl, selectedPeriod, entryDate, description, sourceType, sourceId, voucherSeries, notes]) + }, [lines, isForeign, rate, entryCurrency, computedForeignAmount, submitUrl, editEntryId, selectedPeriod, entryDate, description, sourceType, sourceId, voucherSeries, notes]) const { runSubmit, dialog: activationDialog, confirm: confirmActivation, cancel: cancelActivation } = useSubmitWithAccountActivation(postJournalEntry) @@ -678,6 +695,35 @@ export default function JournalEntryForm({ } } + // Edit an existing draft: PATCH in place (postJournalEntry routes to the + // editEntryId URL) and keep it a draft. No field reset — the host dialog + // closes on success via onUpdated. + const handleSaveEdit = async () => { + if (!selectedPeriod || !description || !isBalanced || periodMismatch) return + setIsSavingDraft(true) + try { + await runSubmit() + toast({ + title: t('toast_updated_title'), + description: t('toast_updated_description'), + }) + onUpdated?.() + } catch (err) { + if (err instanceof Error && err.message === 'cancelled') { + // Activation dialog dismissed — silent + } else { + const anyErr = err as { body?: unknown; status?: number } + toast({ + title: t('toast_update_failed'), + description: getErrorMessage(anyErr.body ?? err, { context: 'journal_entry', statusCode: anyErr.status }), + variant: 'destructive', + }) + } + } finally { + setIsSavingDraft(false) + } + } + // Inline review for the modal (bare): swap the form body to a read-only // 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. @@ -1175,8 +1221,9 @@ export default function JournalEntryForm({

{t('fill_balance_hint')}

- {/* Document attachments */} - {!embedded && ( + {/* Document attachments — hidden when editing a draft; underlag is + managed from the verifikat detail page (JournalEntryAttachments). */} + {!embedded && !editEntryId && (
- {!embedded && ( + {editEntryId ? ( + ) : ( + <> + {!embedded && ( + + )} + + + )} - -
{(!description || !selectedPeriod || isUploading || periodMismatch || incompleteLineCount > 0 || (!isBalanced && submittableLines.length < 2)) && (
diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx index e0b4e8f2..c1365a48 100644 --- a/components/bookkeeping/JournalEntryList.tsx +++ b/components/bookkeeping/JournalEntryList.tsx @@ -106,6 +106,13 @@ export default function JournalEntryList() { const [seriesFilter, setSeriesFilter] = useState('all') const [searchInput, setSearchInput] = useState('') const [search, setSearch] = useState('') + // Verifikat (committed) vs Utkast (drafts) view. Drafts are excluded from the + // committed list server-side and surfaced here behind a count badge. + const [listMode, setListMode] = useState<'committed' | 'drafts'>('committed') + // Collapse correction groups to the live correction (hide storno + reversed + // original). Toggled off via the filter dialog to reveal the full chain. + const [collapseCorrections, setCollapseCorrections] = useState(true) + const [draftCount, setDraftCount] = useState(0) const [pageSizeChoice, setPageSizeChoice] = useState('20') const [pageSizeHydrated, setPageSizeHydrated] = useState(false) const showingAll = pageSizeChoice === 'all' @@ -269,10 +276,18 @@ export default function JournalEntryList() { offset: String(page * pageSize), sort_by: sortBy, }) - if (periodId) params.set('period_id', periodId) - if (dateFrom) params.set('date_from', dateFrom) - if (dateTo) params.set('date_to', dateTo) - if (seriesFilter !== 'all') params.set('series', seriesFilter) + if (listMode === 'drafts') { + // Drafts get their own view spanning all years — they're work-in-progress + // and shouldn't be hidden by the selected fiscal-year scope. + params.set('status', 'draft') + } else { + params.set('exclude_draft', 'true') + if (collapseCorrections) params.set('collapse_corrections', 'true') + if (periodId) params.set('period_id', periodId) + if (dateFrom) params.set('date_from', dateFrom) + if (dateTo) params.set('date_to', dateTo) + if (seriesFilter !== 'all') params.set('series', seriesFilter) + } if (search) params.set('search', search) const res = await fetch(`/api/bookkeeping/journal-entries?${params}`) @@ -289,12 +304,27 @@ export default function JournalEntryList() { // Fetch attachment counts for the loaded entries const ids = loadedEntries.map((e: JournalEntry) => e.id) fetchAttachmentCounts(ids) + + fetchDraftCount() + } + + // Cheap count-only query for the "Utkast" badge — all years, so the badge + // surfaces drafts regardless of the selected fiscal-year scope. + async function fetchDraftCount() { + try { + const res = await fetch('/api/bookkeeping/journal-entries?status=draft&limit=1') + if (!res.ok) return + const { count: total } = await res.json() + setDraftCount(total || 0) + } catch { + // Non-fatal: the badge keeps its last value. + } } useEffect(() => { if (!sortHydrated || !periodHydrated || !pageSizeHydrated) return fetchEntries() - }, [periodId, page, pageSize, sortBy, dateFrom, dateTo, seriesFilter, search, sortHydrated, periodHydrated, pageSizeHydrated]) + }, [periodId, page, pageSize, sortBy, dateFrom, dateTo, seriesFilter, search, listMode, collapseCorrections, sortHydrated, periodHydrated, pageSizeHydrated]) const handleAttachmentCountChange = useCallback((entryId: string, count: number) => { setAttachmentCounts((prev) => ({ ...prev, [entryId]: count })) @@ -304,6 +334,14 @@ export default function JournalEntryList() { setExpandedId(expandedId === id ? null : id) } + function switchMode(mode: 'committed' | 'drafts') { + if (mode === listMode) return + setListMode(mode) + setPage(0) + setSelectedIds(new Set()) + if (mode === 'drafts') setShowMissingOnly(false) + } + const handleCommit = async (entryId: string) => { setCommittingId(entryId) try { @@ -714,6 +752,19 @@ export default function JournalEntryList() { )}
+ + {/* Reveal the storno + reversed-original rows the default view folds + into the surviving correction (3 rows → 1). */} +
+ { setCollapseCorrections(!on); setPage(0) }} + /> + +
@@ -733,10 +784,36 @@ export default function JournalEntryList() {
+ {/* Verifikat vs Utkast. Drafts live in their own view with a count badge so + they don't sink to the last page of the committed list. */} +
+
+ + +
+
+ {/* Active fiscal-year scope — visible without opening the filter dialog so the user always sees which räkenskapsår the ledger is scoped to (BFL period-correctness). Clicking it opens the dialog to change the scope. */} - {periodHydrated && scopeLabel && ( + {listMode === 'committed' && periodHydrated && scopeLabel && (
{t('scope_label')} + ))} +
+

+ {newKeyMode === 'test' ? t('mode_test_help') : t('mode_live_help')} +

+
diff --git a/components/settings/InvoiceSettingsForm.tsx b/components/settings/InvoiceSettingsForm.tsx index df4ce10e..412e68ff 100644 --- a/components/settings/InvoiceSettingsForm.tsx +++ b/components/settings/InvoiceSettingsForm.tsx @@ -63,6 +63,19 @@ export function InvoiceSettingsForm({ settings }: InvoiceSettingsFormProps) { {t('default_notes_help')}

+ +
+ + +

+ {t('default_our_reference_help')} +

+
) } diff --git a/components/settings/PdfPrintSettings.tsx b/components/settings/PdfPrintSettings.tsx index 69f23ce0..9f835dba 100644 --- a/components/settings/PdfPrintSettings.tsx +++ b/components/settings/PdfPrintSettings.tsx @@ -4,6 +4,7 @@ import { useTranslations } from 'next-intl' import { useState, useCallback } from 'react' import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' +import { Badge } from '@/components/ui/badge' import { Textarea } from '@/components/ui/textarea' import { useToast } from '@/components/ui/use-toast' import type { CompanySettings } from '@/types' @@ -112,15 +113,17 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps) />
-
+ {/* Swish on invoices is "coming soon" — the toggle is disabled until the + payment-QR flow ships (gated by SHOW_SWISH_ON_INVOICE in pdf-template). */} +
- +
+ + {t('coming_soon')} +

{t('show_swish_help')}

- saveToggle('invoice_show_swish', v)} - /> +
diff --git a/components/settings/sections/InvoicingSettingsContent.tsx b/components/settings/sections/InvoicingSettingsContent.tsx index e4638ae2..d9e54713 100644 --- a/components/settings/sections/InvoicingSettingsContent.tsx +++ b/components/settings/sections/InvoicingSettingsContent.tsx @@ -42,6 +42,7 @@ export function InvoicingSettingsContent() { next_invoice_number: parseInt(formData.get('next_invoice_number') as string) || 1, invoice_default_days: parseInt(formData.get('invoice_default_days') as string) || 30, invoice_default_notes: (formData.get('invoice_default_notes') as string) || null, + default_our_reference: (formData.get('default_our_reference') as string) || null, } return { updates, diff --git a/customer.json b/customer.json new file mode 100644 index 00000000..3d53bc23 --- /dev/null +++ b/customer.json @@ -0,0 +1,5 @@ +{ + "email": "test@example.com", + "name": "API Test Kund AB", + "customer_type": "swedish_business" +} diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 28641b92..d575f908 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -1061,6 +1061,7 @@ export const UpdateSettingsSchema = z.object({ next_invoice_number: z.number().int().positive().optional(), invoice_default_days: z.number().int().positive().optional(), invoice_default_notes: z.string().nullable().optional(), + default_our_reference: z.string().max(200).nullable().optional(), phone: z.string().optional(), email: z.string().email().optional().or(z.literal('')), website: z.string().optional().or(z.literal('')), diff --git a/lib/api/v1/__tests__/with-api-v1.test.ts b/lib/api/v1/__tests__/with-api-v1.test.ts index 83af45be..5a8cdf8a 100644 --- a/lib/api/v1/__tests__/with-api-v1.test.ts +++ b/lib/api/v1/__tests__/with-api-v1.test.ts @@ -388,6 +388,76 @@ describe('withApiV1 — dry-run', () => { }) }) +describe('withApiV1 — test mode', () => { + it('blocks a test-key write on a non-simulatable endpoint (403 TEST_KEY_WRITE_BLOCKED)', async () => { + // No route modules are imported here, so the endpoint registry is empty → + // getEndpointByConcretePath returns undefined → the wrapper must refuse the + // write rather than let a test key mutate real data. + mockValidate.mockResolvedValue({ + userId: 'user-1', + companyId: 'company-1', + scopes: ['invoices:write'], + mode: 'test', + }) + mockServiceClient.mockReturnValue(makeSupabaseStub({ company_id: 'company-1', role: 'owner' })) + + let handlerCalled = false + const handler = withApiV1( + 'invoices.create', + async (_req, ctx) => { + handlerCalled = true + return ok({ ok: true }, { requestId: ctx.requestId }) + }, + { requireScope: 'invoices:write' }, + ) + + const res = await handler( + makeRequest('https://x.test/api/v1/companies/company-1/invoices', { + method: 'POST', + headers: { Authorization: 'Bearer gnubok_sk_x', 'Content-Type': 'application/json' }, + body: '{}', + }), + companyParams('company-1'), + ) + + expect(res.status).toBe(403) + const body = await res.json() + expect(body.error.code).toBe('TEST_KEY_WRITE_BLOCKED') + expect(handlerCalled).toBe(false) + }) + + it('allows a test-key READ unchanged — no forced dry-run, real data, X-Gnubok-Mode header', async () => { + mockValidate.mockResolvedValue({ + userId: 'user-1', + companyId: 'company-1', + scopes: ['companies:read'], + mode: 'test', + }) + mockServiceClient.mockReturnValue(makeSupabaseStub({ company_id: 'company-1', role: 'owner' })) + + let observedDryRun: boolean | null = null + const handler = withApiV1( + 'companies.get', + async (_req, ctx) => { + observedDryRun = ctx.dryRun + return ok({ ok: true }, { requestId: ctx.requestId }) + }, + { requireScope: 'companies:read' }, + ) + + const res = await handler( + makeRequest('https://x.test/api/v1/companies/company-1', { + headers: { Authorization: 'Bearer gnubok_sk_x' }, + }), + companyParams('company-1'), + ) + + expect(res.status).toBe(200) + expect(observedDryRun).toBe(false) + expect(res.headers.get('X-Gnubok-Mode')).toBe('test') + }) +}) + describe('withApiV1 — public endpoints', () => { it('invokes the handler without authentication for /api/v1/health', async () => { let observedUserId: string | null = null diff --git a/lib/api/v1/registry.ts b/lib/api/v1/registry.ts index e589df37..91472de6 100644 --- a/lib/api/v1/registry.ts +++ b/lib/api/v1/registry.ts @@ -135,6 +135,26 @@ export function getEndpoint(method: HttpMethod, path: string): EndpointDefinitio return ENDPOINTS.get(`${method} ${path}`) } +/** + * Resolve the registered endpoint for a CONCRETE request path (e.g. + * `/api/v1/companies/abc/customers`) by matching it against the registered + * `:param` patterns. Used by the wrapper to read an endpoint's `dryRunSupported` + * flag at request time — the route module being served has already run its + * `registerEndpoint()` call, so its pattern is present. Returns undefined when + * no pattern matches (the wrapper treats that as "cannot be simulated"). + */ +export function getEndpointByConcretePath( + method: string, + concretePath: string, +): EndpointDefinition | undefined { + for (const def of ENDPOINTS.values()) { + if (def.method !== method) continue + const regex = new RegExp('^' + def.path.replace(/:[^/]+/g, '[^/]+') + '$') + if (regex.test(concretePath)) return def + } + return undefined +} + // ────────────────────────────────────────────────────────────────── // Minimal Zod → JSON Schema converter // ────────────────────────────────────────────────────────────────── diff --git a/lib/api/v1/with-api-v1.ts b/lib/api/v1/with-api-v1.ts index 4db91fe9..03fbef34 100644 --- a/lib/api/v1/with-api-v1.ts +++ b/lib/api/v1/with-api-v1.ts @@ -51,6 +51,7 @@ import { // idempotent (guarded by a module-level boolean). ensureInitialized() import { resolveRequiredScope } from '@/lib/auth/scopes' +import { getEndpointByConcretePath } from './registry' import { checkIdempotencyKey, hashRequest, @@ -79,7 +80,11 @@ export interface ApiV1Context { apiKeyName: string | undefined /** Scopes granted to the calling key. */ scopes: ApiKeyScope[] - /** test|live — handlers branch on this to short-circuit external providers in test mode. */ + /** + * test|live. Test keys are simulation-only — the wrapper forces `dryRun` on + * for every write, so handlers never need to special-case `mode`; they just + * honor `dryRun` as usual. + */ mode: ApiKeyMode /** Service-role Supabase client (no cookies). All queries MUST filter by company_id. */ supabase: SupabaseClient @@ -353,6 +358,28 @@ export function withApiV1

`). The infix is purely cosmetic — the authoritative + // mode is the `mode` column on api_keys, read back by hash in validateApiKey, + // so nothing trusts the key string. Both variants keep the `gnubok_sk_` + // prefix so the `startsWith(KEY_PREFIX)` check in validateApiKey still holds. + const key = mode === 'test' ? `${KEY_PREFIX}test_${random}` : `${KEY_PREFIX}${random}` const hash = hashApiKey(key) + // First 18 chars: 'gnubok_sk_test_xyz' for test keys, 'gnubok_sk_xxxxxxxx' + // for live — the stored prefix is what the settings UI shows, so the test_ + // infix is visible in the key list without exposing the secret. const prefix = key.slice(0, KEY_PREFIX.length + 8) return { key, hash, prefix } } diff --git a/lib/bookkeeping/__tests__/update-draft-entry.test.ts b/lib/bookkeeping/__tests__/update-draft-entry.test.ts new file mode 100644 index 00000000..ebb86cd1 --- /dev/null +++ b/lib/bookkeeping/__tests__/update-draft-entry.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest' +import { updateDraftEntry } from '../engine' +import { + CannotEditNonDraftError, + JournalEntryNotFoundError, + JournalEntryNotBalancedError, +} from '../errors' +import { createQueuedMockSupabase } from '@/tests/helpers' +import type { CreateJournalEntryInput } from '@/types' + +const balancedInput: CreateJournalEntryInput = { + fiscal_period_id: 'period-1', + entry_date: '2026-06-01', + description: 'Test draft', + source_type: 'manual', + voucher_series: 'A', + lines: [ + { account_number: '1930', debit_amount: 100, credit_amount: 0 }, + { account_number: '3001', debit_amount: 0, credit_amount: 100 }, + ], +} + +describe('updateDraftEntry', () => { + it('throws JournalEntryNotFoundError when the entry does not exist', async () => { + const q = createQueuedMockSupabase() + q.enqueue({ data: null, error: { message: 'not found' } }) + await expect( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + updateDraftEntry(q.supabase as any, 'company-1', 'user-1', 'missing', balancedInput) + ).rejects.toBeInstanceOf(JournalEntryNotFoundError) + }) + + it('refuses to edit a posted entry — only drafts are editable', async () => { + const q = createQueuedMockSupabase() + q.enqueue({ data: { id: 'e1', status: 'posted', voucher_series: 'A' }, error: null }) + await expect( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + updateDraftEntry(q.supabase as any, 'company-1', 'user-1', 'e1', balancedInput) + ).rejects.toBeInstanceOf(CannotEditNonDraftError) + }) + + it('rejects an unbalanced draft before mutating anything', async () => { + const q = createQueuedMockSupabase() + q.enqueue({ data: { id: 'e1', status: 'draft', voucher_series: 'A' }, error: null }) + const unbalanced: CreateJournalEntryInput = { + ...balancedInput, + lines: [ + { account_number: '1930', debit_amount: 100, credit_amount: 0 }, + { account_number: '3001', debit_amount: 0, credit_amount: 50 }, + ], + } + await expect( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + updateDraftEntry(q.supabase as any, 'company-1', 'user-1', 'e1', unbalanced) + ).rejects.toBeInstanceOf(JournalEntryNotBalancedError) + }) +}) diff --git a/lib/bookkeeping/engine.ts b/lib/bookkeeping/engine.ts index 9741eda0..c8d4775d 100644 --- a/lib/bookkeeping/engine.ts +++ b/lib/bookkeeping/engine.ts @@ -4,6 +4,7 @@ import { createLogger } from '@/lib/logger' import { AccountsNotInChartError, BookkeepingDatabaseError, + CannotEditNonDraftError, CannotReverseNonPostedError, EntryAlreadyReversedError, EntryDateOutsideFiscalPeriodError, @@ -352,6 +353,138 @@ export async function createDraftEntry( return result } +/** + * Update an existing DRAFT journal entry in place — header + lines. Only drafts + * are editable; committed entries (posted/reversed/cancelled) are immutable per + * BFL 5 kap. and rejected with CannotEditNonDraftError (the DB immutability + * trigger is the backstop). Mirrors createDraftEntry's validate-everything-first + * order so an unbalanced set, a bad period, or a locked period fails before any + * row is mutated — the header UPDATE is the first write, so a locked period + * aborts cleanly with the draft untouched. + */ +export async function updateDraftEntry( + supabase: SupabaseClient, + companyId: string, + userId: string, + entryId: string, + input: CreateJournalEntryInput +): Promise { + // Load the entry and assert it is an editable draft. + const { data: existing, error: loadError } = await supabase + .from('journal_entries') + .select('id, status, voucher_series') + .eq('id', entryId) + .eq('company_id', companyId) + .single() + + if (loadError || !existing) { + throw new JournalEntryNotFoundError() + } + if (existing.status !== 'draft') { + throw new CannotEditNonDraftError(existing.status as string) + } + + // Same balance gate as createDraftEntry. + const balance = validateBalance(input.lines) + if (!balance.valid) { + throw new JournalEntryNotBalancedError(balance.totalDebit, balance.totalCredit, 'draft') + } + + // Entry date must fall within the selected fiscal period. + const { data: period, error: periodError } = await supabase + .from('fiscal_periods') + .select('name, period_start, period_end') + .eq('id', input.fiscal_period_id) + .eq('company_id', companyId) + .single() + + if (periodError || !period) { + throw new FiscalPeriodNotFoundError() + } + if (input.entry_date < period.period_start || input.entry_date > period.period_end) { + throw new EntryDateOutsideFiscalPeriodError( + input.entry_date, + period.name, + period.period_start, + period.period_end + ) + } + + // Resolve account IDs (seeding standard BAS accounts on demand) up front, so + // the line insert below cannot fail on a missing account — same as create. + const accountIdMap = await resolveAccountIds(supabase, companyId, input.lines) + const allAccountNumbers = [...new Set(input.lines.map((l) => l.account_number))] + let missingAccounts = allAccountNumbers.filter((num) => !accountIdMap.has(num)) + if (missingAccounts.length > 0) { + const seeded = await backfillStandardBASAccounts(supabase, companyId, userId, missingAccounts) + if (seeded.length > 0) { + const refreshed = await resolveAccountIds(supabase, companyId, input.lines) + for (const [num, id] of refreshed) accountIdMap.set(num, id) + missingAccounts = allAccountNumbers.filter((num) => !accountIdMap.has(num)) + } + if (missingAccounts.length > 0) { + throw new AccountsNotInChartError(missingAccounts) + } + } + + const resolvedSeries = input.voucher_series || (existing.voucher_series as string) || 'A' + + // All validation passed — mutate. Update the header first; a locked/closed + // period blocks this write (enforce_period_lock) before any line is touched. + // source_type / source_id / status are intentionally preserved. + const { error: headerError } = await supabase + .from('journal_entries') + .update({ + fiscal_period_id: input.fiscal_period_id, + entry_date: input.entry_date, + description: input.description, + voucher_series: resolvedSeries, + notes: input.notes || null, + }) + .eq('id', entryId) + .eq('company_id', companyId) + + if (headerError) { + throw new BookkeepingDatabaseError('create_draft_entry', headerError.message) + } + + // Replace the lines: delete the old set, insert the new one. + const { error: deleteError } = await supabase + .from('journal_entry_lines') + .delete() + .eq('journal_entry_id', entryId) + + if (deleteError) { + throw new BookkeepingDatabaseError('create_entry_lines', deleteError.message) + } + + const lineInserts = buildLineInserts(entryId, input.lines, accountIdMap) + const { error: linesError } = await supabase + .from('journal_entry_lines') + .insert(lineInserts) + + if (linesError) { + log.error('update draft: insert journal_entry_lines failed', linesError, { + operation: 'create_entry_lines', + companyId, + userId, + entityType: 'journal_entry', + entityId: entryId, + lineCount: lineInserts.length, + pgCode: (linesError as { code?: string }).code, + }) + throw new BookkeepingDatabaseError('create_entry_lines', linesError.message) + } + + const { data: completeEntry } = await supabase + .from('journal_entries') + .select('*, lines:journal_entry_lines(*)') + .eq('id', entryId) + .single() + + return completeEntry as JournalEntry +} + /** * Commit a draft entry: assigns voucher number and transitions to 'posted' * Uses the atomic commit_journal_entry RPC so the voucher number increment diff --git a/lib/bookkeeping/errors.ts b/lib/bookkeeping/errors.ts index 3f0b3a93..ba537116 100644 --- a/lib/bookkeeping/errors.ts +++ b/lib/bookkeeping/errors.ts @@ -11,6 +11,7 @@ export const ENTRY_DATE_OUTSIDE_FISCAL_PERIOD = 'ENTRY_DATE_OUTSIDE_FISCAL_PERIO export const JOURNAL_ENTRY_NOT_FOUND = 'JOURNAL_ENTRY_NOT_FOUND' as const export const CANNOT_REVERSE_NON_POSTED = 'CANNOT_REVERSE_NON_POSTED' as const export const CANNOT_CORRECT_NON_POSTED = 'CANNOT_CORRECT_NON_POSTED' as const +export const CANNOT_EDIT_NON_DRAFT = 'CANNOT_EDIT_NON_DRAFT' as const export const ENTRY_ALREADY_REVERSED = 'ENTRY_ALREADY_REVERSED' as const export const CURRENCY_REVALUATION_ALREADY_EXISTS = 'CURRENCY_REVALUATION_ALREADY_EXISTS' as const export const INVALID_MAPPING_RESULT = 'INVALID_MAPPING_RESULT' as const @@ -126,6 +127,20 @@ export class CannotCorrectNonPostedError extends Error { } } +/** + * Raised when an edit is attempted on a committed entry. Only drafts are + * editable in place; posted/reversed/cancelled entries are immutable per BFL + * 5 kap. (corrections go through storno). The DB immutability trigger is the + * backstop — this gives a clean, translatable 409 before we reach it. + */ +export class CannotEditNonDraftError extends Error { + readonly code = CANNOT_EDIT_NON_DRAFT + constructor(public readonly currentStatus: string) { + super('Only draft entries can be edited') + this.name = 'CannotEditNonDraftError' + } +} + export class EntryAlreadyReversedError extends Error { readonly code = ENTRY_ALREADY_REVERSED constructor() { @@ -269,6 +284,7 @@ export function isBookkeepingError(err: unknown): boolean { err instanceof JournalEntryNotFoundError || err instanceof CannotReverseNonPostedError || err instanceof CannotCorrectNonPostedError || + err instanceof CannotEditNonDraftError || err instanceof EntryAlreadyReversedError || err instanceof CurrencyRevaluationAlreadyExistsError || err instanceof InvalidMappingResultError || @@ -396,6 +412,19 @@ export function bookkeepingErrorResponse(err: unknown): NextResponse | null { ) } + if (err instanceof CannotEditNonDraftError) { + return NextResponse.json( + { + error: { + code: err.code, + message: err.message, + details: { currentStatus: err.currentStatus }, + }, + }, + { status: 409 } + ) + } + if (err instanceof EntryAlreadyReversedError) { return NextResponse.json( { error: { code: err.code, message: err.message } }, diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 6d227054..8b7ebc9d 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -117,6 +117,16 @@ const GENERIC: Record = { resource: 'Accounted://capabilities', }, }, + TEST_KEY_WRITE_BLOCKED: { + httpStatus: 403, + message_sv: + 'Den här åtgärden kan inte simuleras och är därför inte tillgänglig med en testnyckel. Använd en live-nyckel.', + message_en: + 'This endpoint cannot be simulated, so it is not available with a test key. Test keys force dry-run on every write; use a live key for endpoints that do not support dry-run.', + remediation: { + description: 'Use a live key for this endpoint, or pick an endpoint that supports dry-run.', + }, + }, } // ───────────────────────────────────────────────────────────────── diff --git a/lib/import/__tests__/sie-import.test.ts b/lib/import/__tests__/sie-import.test.ts index 1d483b67..53026326 100644 --- a/lib/import/__tests__/sie-import.test.ts +++ b/lib/import/__tests__/sie-import.test.ts @@ -370,7 +370,9 @@ describe('ensureFiscalPeriod validation', () => { { data: null, error: null }, { data: [], error: null }, { data: [], error: null }, // no earlier period + { data: [], error: null }, // no predecessor in the continuity chain { data: { id: 'new-period-id' }, error: null }, // insert result + { data: [], error: null }, // no successor to relink ]) const id = await ensureFiscalPeriod( @@ -393,7 +395,9 @@ describe('ensureFiscalPeriod validation', () => { { data: null, error: null }, // containing check — no match { data: [], error: null }, // overlapping check — none (2017 vs 2026) { data: [], error: null }, // no earlier period than 2017-07-28 + { data: [], error: null }, // no predecessor in the continuity chain { data: { id: 'retro-first-year-id' }, error: null }, // insert + { data: [], error: null }, // no successor to relink ]) const id = await ensureFiscalPeriod( @@ -482,7 +486,9 @@ describe('ensureFiscalPeriod validation', () => { { data: [], error: null }, // journal_entries — none { data: [], error: null }, // earlier-period check — none (mid-month start) { data: null, error: null }, // delete result + { data: [], error: null }, // no predecessor in the continuity chain { data: { id: 'replaced-id' }, error: null }, // insert result + { data: [], error: null }, // no successor to relink ]) const id = await ensureFiscalPeriod( diff --git a/lib/import/sie-import.ts b/lib/import/sie-import.ts index 1d6ee29e..bd966ab0 100644 --- a/lib/import/sie-import.ts +++ b/lib/import/sie-import.ts @@ -487,6 +487,20 @@ export async function ensureFiscalPeriod( ? `Räkenskapsår ${startYear}` : `Räkenskapsår ${startYear}/${endYear}` + // Link the BFNAR 2013:2 continuity chain so the resultatrapport can find the + // prior year for its comparison column. Mirrors the manual fiscal-periods + // route: point this period at its closest predecessor, then relink the + // immediate successor (if any) to follow this one — so multi-year SIE files + // chain correctly regardless of the order #RAR years are processed in. + const { data: predecessors } = await supabase + .from('fiscal_periods') + .select('id') + .eq('company_id', companyId) + .lt('period_end', startDate) + .order('period_end', { ascending: false }) + .limit(1) + const previousPeriodId = predecessors && predecessors.length > 0 ? predecessors[0].id : null + const { data: newPeriod, error } = await supabase .from('fiscal_periods') .insert({ @@ -496,6 +510,7 @@ export async function ensureFiscalPeriod( period_end: endDate, is_closed: false, opening_balances_set: false, + previous_period_id: previousPeriodId, }) .select() .single() @@ -504,6 +519,24 @@ export async function ensureFiscalPeriod( throw new Error(`Failed to create fiscal period: ${error?.message}`) } + // Relink the immediate successor (e.g. when an earlier year is imported after + // a later one) so the chain holds in both directions. + const { data: successors } = await supabase + .from('fiscal_periods') + .select('id') + .eq('company_id', companyId) + .gt('period_start', endDate) + .neq('id', newPeriod.id) + .order('period_start', { ascending: true }) + .limit(1) + if (successors && successors.length > 0) { + await supabase + .from('fiscal_periods') + .update({ previous_period_id: newPeriod.id }) + .eq('id', successors[0].id) + .eq('company_id', companyId) + } + return newPeriod.id } diff --git a/lib/invoices/pdf-render-helpers.ts b/lib/invoices/pdf-render-helpers.ts index b5f55512..8d7cc9f5 100644 --- a/lib/invoices/pdf-render-helpers.ts +++ b/lib/invoices/pdf-render-helpers.ts @@ -2,11 +2,17 @@ * Shared helpers for invoice PDF render call sites. * * Wraps `brandingFromCompanySettings` so every PDF-rendering route gets a - * consistent branding object. + * consistent branding object, and builds the optional Swish payment QR. */ -import type { CompanySettings } from '@/types' -import { brandingFromCompanySettings, type InvoiceBranding } from '@/lib/invoices/pdf-template' +import QRCode from 'qrcode' +import type { CompanySettings, Invoice } from '@/types' +import { brandingFromCompanySettings, SHOW_SWISH_ON_INVOICE, type InvoiceBranding } from '@/lib/invoices/pdf-template' +import { buildSwishQrPayload } from '@/lib/payments/swish' +import { getDisplayTotal } from '@/lib/invoices/rounding' +import { createLogger } from '@/lib/logger' + +const log = createLogger('invoice.swish-qr') export interface InvoicePdfRenderExtras { branding: InvoiceBranding @@ -15,3 +21,46 @@ export interface InvoicePdfRenderExtras { export function prepareInvoicePdfRender(company: CompanySettings): InvoicePdfRenderExtras { return { branding: brandingFromCompanySettings(company) } } + +/** + * Build the Swish payment QR for an invoice as a PNG data URL, or null when: + * Swish display is off, there's no/invalid Swish number, the invoice isn't in + * SEK (Swish is SEK-only), or the amount is not positive. Generated locally with + * the `qrcode` lib — no call to any Swish API. Pass the result to InvoicePDF's + * `swishQrDataUrl` prop; the template gates rendering on the same payment box + * that already shows the Swish number. + */ +export async function buildSwishQrDataUrl( + company: CompanySettings, + invoice: Invoice, +): Promise { + // Swish on invoices is "coming soon" — gated off in pdf-template. Bail before + // any work while the feature is disabled. + if (!SHOW_SWISH_ON_INVOICE) return null + // Swish display off is the normal "no QR" case — stay quiet. Every other + // skip is logged so a missing QR is diagnosable instead of silent. + if (!(company.invoice_show_swish ?? false)) return null + if ((invoice.currency ?? 'SEK') !== 'SEK') { + log.info('swish QR skipped: invoice not in SEK', { invoiceId: invoice.id, currency: invoice.currency }) + return null + } + const amount = getDisplayTotal(invoice, company).displayed + const payload = buildSwishQrPayload(company.swish, amount, invoice.invoice_number ?? '') + if (!payload) { + log.warn('swish QR skipped: invalid number or non-positive amount', { + invoiceId: invoice.id, + hasSwish: !!company.swish, + amount, + }) + return null + } + try { + return await QRCode.toDataURL(payload, { margin: 1, width: 240, errorCorrectionLevel: 'M' }) + } catch (err) { + log.warn('swish QR generation failed', { + invoiceId: invoice.id, + error: err instanceof Error ? err.message : String(err), + }) + return null + } +} diff --git a/lib/invoices/pdf-template.tsx b/lib/invoices/pdf-template.tsx index b93e3fb0..c5746ed4 100644 --- a/lib/invoices/pdf-template.tsx +++ b/lib/invoices/pdf-template.tsx @@ -83,6 +83,8 @@ const LABELS = { bic: 'BIC/SWIFT:', ocr: 'OCR/Referens:', paymentReference: 'Betalningsreferens:', + invoiceNumber: 'Fakturanummer:', + swishQrCaption: 'Skanna för att betala med Swish', // Footer orgNoLong: 'Org.nr:', vatRegNo: 'Momsreg.nr:', @@ -146,6 +148,8 @@ const LABELS = { bic: 'BIC/SWIFT:', ocr: 'Reference:', paymentReference: 'Payment reference:', + invoiceNumber: 'Invoice number:', + swishQrCaption: 'Scan to pay with Swish', orgNoLong: 'Reg. no.:', vatRegNo: 'VAT reg. no.:', // Statutory Swedish phrase — kept verbatim in both locales. Peppol SE-R-005 @@ -155,6 +159,11 @@ const LABELS = { }, } as const +// Swish on invoices (the number row + the payment QR) is "coming soon" — gated +// off until the QR flow is finished. Flip to true to re-enable both at once; +// the settings "Visa Swish" toggle is disabled while this is false. +export const SHOW_SWISH_ON_INVOICE = false + // Labor-only disclaimer for the ROT/RUT block. Kept Swedish-only in both // locales — references Skatteverket's fakturamodell directly, which is a // statutory Swedish concept and has no formal English equivalent. @@ -622,9 +631,12 @@ interface InvoicePDFProps { * suite and for callers that haven't yet been migrated to forward branding. */ branding?: InvoiceBranding + /** Pre-rendered Swish payment QR (PNG data URL). Built offline in + * pdf-render-helpers; null/omitted renders no QR. */ + swishQrDataUrl?: string | null } -export function InvoicePDF({ invoice, customer, items, company, originalInvoiceNumber, isPreview, language, branding }: InvoicePDFProps) { +export function InvoicePDF({ invoice, customer, items, company, originalInvoiceNumber, isPreview, language, branding, swishQrDataUrl }: InvoicePDFProps) { const lang: PdfLang = language ?? customer.language ?? 'sv' const L = LABELS[lang] // Build the stylesheet per-render so each invoice picks up its company's @@ -1039,7 +1051,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN {company.plusgiro} )} - {company.swish && (company.invoice_show_swish ?? false) && ( + {SHOW_SWISH_ON_INVOICE && company.swish && (company.invoice_show_swish ?? false) && ( {L.swish} {company.swish} @@ -1061,16 +1073,22 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN {L.dueDate} {formatDate(invoice.due_date)} + {invoice.invoice_number && ( + + {L.invoiceNumber} + {invoice.invoice_number} + + )} {(company.invoice_show_ocr ?? true) && (company.bankgiro || company.plusgiro) && lang === 'sv' && ( {L.ocr} {invoice.invoice_number ? generateOcrReference(invoice.invoice_number) : '—'} )} - {lang !== 'sv' && invoice.invoice_number && ( - - {L.paymentReference} - {invoice.invoice_number} + {swishQrDataUrl && ( + + + {L.swishQrCaption} )} diff --git a/lib/invoices/recurring-schedule-service.ts b/lib/invoices/recurring-schedule-service.ts index d24f366c..4027e29b 100644 --- a/lib/invoices/recurring-schedule-service.ts +++ b/lib/invoices/recurring-schedule-service.ts @@ -19,7 +19,7 @@ import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number' import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries' import { renderToBuffer } from '@react-pdf/renderer' import { InvoicePDF } from '@/lib/invoices/pdf-template' -import { prepareInvoicePdfRender } from '@/lib/invoices/pdf-render-helpers' +import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers' import { getEmailService } from '@/lib/email/service' import { generateInvoiceEmailHtml, @@ -385,6 +385,7 @@ async function sendInvoiceFromSchedule( // receive a "UTKAST" stamp. const renderableInvoice = { ...invoice, status: 'sent' as const } const { branding } = prepareInvoicePdfRender(company) + const swishQrDataUrl = await buildSwishQrDataUrl(company, renderableInvoice) const pdfBuffer = await renderToBuffer( InvoicePDF({ invoice: renderableInvoice, @@ -392,6 +393,7 @@ async function sendInvoiceFromSchedule( items, company, branding, + swishQrDataUrl, }), ) diff --git a/lib/payments/__tests__/swish.test.ts b/lib/payments/__tests__/swish.test.ts index 41bb98f4..4b3a7901 100644 --- a/lib/payments/__tests__/swish.test.ts +++ b/lib/payments/__tests__/swish.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { normaliseSwish, isValidSwish } from '../swish' +import { normaliseSwish, isValidSwish, buildSwishQrPayload } from '../swish' describe('normaliseSwish', () => { it('strips whitespace and hyphens', () => { @@ -37,3 +37,37 @@ describe('isValidSwish', () => { expect(isValidSwish('123abc4567')).toBe(false) }) }) + +describe('buildSwishQrPayload', () => { + it('builds a fully-locked Type C payload for a Swish-företag number', () => { + expect(buildSwishQrPayload('1234567890', 1250, 'Faktura 100')).toBe('C1234567890;1250.00;Faktura 100;0') + }) + + it('works for a mobile-number payee with the same syntax', () => { + expect(buildSwishQrPayload('0701234567', 99.5, 'F-1')).toBe('C0701234567;99.50;F-1;0') + }) + + it('normalises spaces/hyphens in the number', () => { + expect(buildSwishQrPayload('123 456 78 90', 10, 'x')).toBe('C1234567890;10.00;x;0') + }) + + it('strips the ; field delimiter from the message and trims it', () => { + expect(buildSwishQrPayload('1234567890', 10, ' a;b ')).toBe('C1234567890;10.00;a b;0') + }) + + it('formats the amount with two decimals', () => { + expect(buildSwishQrPayload('1234567890', 100, 'x')).toBe('C1234567890;100.00;x;0') + expect(buildSwishQrPayload('1234567890', 1234.5, 'x')).toBe('C1234567890;1234.50;x;0') + }) + + it('returns null for an invalid or empty number', () => { + expect(buildSwishQrPayload('12345', 10, 'x')).toBeNull() + expect(buildSwishQrPayload('', 10, 'x')).toBeNull() + expect(buildSwishQrPayload(null, 10, 'x')).toBeNull() + }) + + it('returns null for a non-positive amount', () => { + expect(buildSwishQrPayload('1234567890', 0, 'x')).toBeNull() + expect(buildSwishQrPayload('1234567890', -5, 'x')).toBeNull() + }) +}) diff --git a/lib/payments/swish.ts b/lib/payments/swish.ts index 8b92f422..10c935d7 100644 --- a/lib/payments/swish.ts +++ b/lib/payments/swish.ts @@ -10,6 +10,8 @@ * canonicalised. */ +import { roundOre } from '@/lib/money' + const SWISH_FORETAG = /^123\d{7}$/ const SWEDISH_MOBILE = /^07\d{8}$/ @@ -21,3 +23,29 @@ export function normaliseSwish(value: string | null | undefined): string { export function isValidSwish(normalised: string): boolean { return normalised === '' || SWISH_FORETAG.test(normalised) || SWEDISH_MOBILE.test(normalised) } + +/** + * Build the Swish "Type C" QR payload — `C;;;`. + * + * editmask 0 locks payee, amount and message, so the Swish app opens prefilled + * and uneditable. This is the documented format the Swish app scans directly, + * so the QR can be generated entirely offline (no call to Swish's QR API). + * Works for both Swish-företag (123XXXXXXX) and mobile (07XXXXXXXX) payees. + * + * Returns null when the number is missing/invalid or the amount is not positive. + * Spec: Swish QR Code Design Specification (Getswish AB). + */ +export function buildSwishQrPayload( + swishNumber: string | null | undefined, + amount: number, + message: string, +): string | null { + const number = normaliseSwish(swishNumber) + if (!number || !isValidSwish(number)) return null + if (!(amount > 0)) return null + // Amount uses a dot decimal with at most two decimals. The message must not + // contain the ';' field delimiter; cap its length to keep the QR scannable. + const amt = roundOre(amount).toFixed(2) + const msg = (message ?? '').replace(/;/g, ' ').trim().slice(0, 50) + return `C${number};${amt};${msg};0` +} diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index 3ed99773..60f03842 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -65,7 +65,7 @@ import { import { uploadDocument, linkToJournalEntry } from '@/lib/core/documents/document-service' import { renderToBuffer } from '@react-pdf/renderer' import { InvoicePDF } from '@/lib/invoices/pdf-template' -import { prepareInvoicePdfRender } from '@/lib/invoices/pdf-render-helpers' +import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers' import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number' import { createLogger } from '@/lib/logger' import { appendProcessingHistory } from '@/lib/processing-history/append' @@ -996,6 +996,7 @@ async function commitSendInvoice( // would stamp the customer's PDF with "UTKAST – inte en giltig faktura". const renderableInvoice = { ...(invoice as Invoice), status: 'sent' as const } const { branding } = prepareInvoicePdfRender(company as CompanySettings) + const swishQrDataUrl = await buildSwishQrDataUrl(company as CompanySettings, renderableInvoice) const pdfBuffer = await renderToBuffer( InvoicePDF({ invoice: renderableInvoice, @@ -1004,6 +1005,7 @@ async function commitSendInvoice( company: company as CompanySettings, originalInvoiceNumber, branding, + swishQrDataUrl, }) ) diff --git a/lib/reports/__tests__/resultatrapport.test.ts b/lib/reports/__tests__/resultatrapport.test.ts index 6153cf4e..d954f0bf 100644 --- a/lib/reports/__tests__/resultatrapport.test.ts +++ b/lib/reports/__tests__/resultatrapport.test.ts @@ -219,6 +219,57 @@ describe('generateResultatrapport', () => { expect(report.groups[0].rows[0].account_number).toBe('3001') }) + it('falls back to the date-adjacent prior period when previous_period_id is null', async () => { + // Reproduces the multi-year-SIE bug: the continuity chain was never linked, + // so the comparison must resolve the prior year by date instead. + const q = createQueuedMockSupabase() + q.enqueue({ + data: { period_start: '2026-01-01', period_end: '2026-12-31', previous_period_id: null }, + error: null, + }) + // Date-range fallback finds the immediately-preceding period. + q.enqueue({ data: [{ id: 'period-0' }], error: null }) + // Prior-period dates. + q.enqueue({ data: { period_start: '2025-01-01', period_end: '2025-12-31' }, error: null }) + + mockTrialBalance + .mockResolvedValueOnce( + tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 200000 })]) + ) + .mockResolvedValueOnce( + tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 150000 })]) + ) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1') + + expect(report.groups[0].rows[0].current_period).toBe(200000) + expect(report.groups[0].rows[0].prior_period).toBe(150000) + expect(report.prior_period).toEqual({ start: '2025-01-01', end: '2025-12-31' }) + // The fallback resolved 'period-0' and the prior TB was fetched for it. + expect(mockTrialBalance).toHaveBeenNthCalledWith(2, expect.anything(), 'company-1', 'period-0') + }) + + it('leaves the prior column empty when there is no earlier period at all', async () => { + const q = createQueuedMockSupabase() + q.enqueue({ + data: { period_start: '2026-01-01', period_end: '2026-12-31', previous_period_id: null }, + error: null, + }) + q.enqueue({ data: [], error: null }) // no date-adjacent predecessor + + mockTrialBalance.mockResolvedValueOnce( + tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 100000 })]) + ) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1') + + expect(report.prior_period).toBeNull() + expect(report.net_result_prior).toBe(0) + expect(mockTrialBalance).toHaveBeenCalledTimes(1) + }) + it('throws when fiscal period not found', async () => { const q = createQueuedMockSupabase() q.enqueue({ data: null, error: null }) diff --git a/lib/reports/resultatrapport.ts b/lib/reports/resultatrapport.ts index 6e2a3801..80ff557a 100644 --- a/lib/reports/resultatrapport.ts +++ b/lib/reports/resultatrapport.ts @@ -61,18 +61,36 @@ export async function generateResultatrapport( let priorRows: TrialBalanceRow[] = [] let priorPeriodInfo: { start: string; end: string } | null = null const isFullPeriod = !options?.fromDate && !options?.toDate - if (isFullPeriod && period.previous_period_id) { - const { data: prior } = await supabase - .from('fiscal_periods') - .select('period_start, period_end') - .eq('id', period.previous_period_id) - .eq('company_id', companyId) - .single() + if (isFullPeriod) { + // Prefer the explicit continuity chain; fall back to the period that ends + // immediately before this one. The fallback keeps the comparison working + // for companies whose chain was never linked — e.g. multi-year SIE imports + // created before the importer started setting previous_period_id. + let priorPeriodId: string | null = period.previous_period_id ?? null + if (!priorPeriodId) { + const { data: priorByDate } = await supabase + .from('fiscal_periods') + .select('id') + .eq('company_id', companyId) + .lt('period_end', period.period_start) + .order('period_end', { ascending: false }) + .limit(1) + priorPeriodId = priorByDate && priorByDate.length > 0 ? priorByDate[0].id : null + } - if (prior) { - const priorTb = await generateTrialBalance(supabase, companyId, period.previous_period_id) - priorRows = filterPnl(priorTb.rows) - priorPeriodInfo = { start: prior.period_start, end: prior.period_end } + if (priorPeriodId) { + const { data: prior } = await supabase + .from('fiscal_periods') + .select('period_start, period_end') + .eq('id', priorPeriodId) + .eq('company_id', companyId) + .single() + + if (prior) { + const priorTb = await generateTrialBalance(supabase, companyId, priorPeriodId) + priorRows = filterPnl(priorTb.rows) + priorPeriodInfo = { start: prior.period_start, end: prior.period_end } + } } } diff --git a/messages/en.json b/messages/en.json index fea11968..664ed50a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1228,6 +1228,9 @@ "swish_error": "Invalid Swish number (business number 123XXXXXXX or mobile number 07XXXXXXXX)" }, "settings_invoice_form": { + "default_our_reference_label": "Default \"Our reference\"", + "default_our_reference_placeholder": "E.g. your name", + "default_our_reference_help": "Pre-filled on new invoices. Editable per invoice.", "heading": "Invoice settings", "prefix_label": "Invoice prefix", "prefix_placeholder": "e.g. F-", @@ -1238,6 +1241,7 @@ "default_notes_help": "Suggested automatically for new invoices." }, "settings_pdf_print": { + "coming_soon": "Coming soon", "heading": "Print & PDF", "toast_save_failed": "Could not save", "ore_rounding_label": "Öre rounding", @@ -1249,7 +1253,7 @@ "show_plusgiro_label": "Show plusgiro", "show_plusgiro_help": "Show plusgiro number on invoice printout", "show_swish_label": "Show Swish", - "show_swish_help": "Show Swish number on invoice printout", + "show_swish_help": "Show swish number and qr code on invoice", "show_logo_label": "Show logo", "show_logo_help": "Show uploaded logo in the invoice header", "show_company_name_label": "Show company name on invoice", @@ -1477,6 +1481,12 @@ "toast_create_failed": "Could not create key", "toast_revoked": "Key revoked", "toast_revoke_failed": "Could not revoke key", + "mode_label": "Environment", + "mode_live": "Live", + "mode_test": "Test", + "mode_live_help": "Live keys operate on your real company and its books.", + "mode_test_help": "Test keys simulate every call (forced dry-run) — you see exactly what would happen, but nothing is saved or sent.", + "badge_test": "Test", "revoke_dialog_title": "Revoke API key", "revoke_dialog_description": "\"{name}\" will be permanently revoked. Any clients using the key will stop working immediately.", "revoke_confirm": "Revoke", @@ -2134,6 +2144,10 @@ "cancel": "Cancel" }, "invoice_editor": { + "row_menu_set_account": "Set sales account", + "row_menu_remove_account": "Remove sales account", + "revenue_account_label": "Sales account", + "revenue_account_hint": "Leave blank to derive the account from the VAT rate. Ignored for reverse charge and export.", "ore_rounding_label": "Öre rounding", "ore_rounding_help": "Round the invoice total to whole kronor", "back": "Back", @@ -2988,6 +3002,9 @@ "send_failed_fallback": "Please try again." }, "journal_list": { + "mode_vouchers": "Vouchers", + "mode_drafts": "Drafts", + "show_correction_chain": "Show storno & corrected entries", "loading": "Loading journal entries...", "empty_title": "No journal entries", "empty_description": "Journal entries are created automatically from invoicing and transaction posting, or manually via the \"New entry\" tab.", @@ -3155,6 +3172,7 @@ "current": "Current" }, "journal_detail": { + "edit_draft": "Edit", "back": "Back to bookkeeping", "loading": "Loading journal entry...", "error_not_found": "Journal entry not found", @@ -3219,6 +3237,10 @@ "delete_dialog_entry_body": "The journal entry and its lines are removed. Linked transactions and invoices keep their data but are marked as unposted. Documents (receipts, files) are kept but unlinked." }, "journal_form": { + "save_edit": "Save changes", + "toast_updated_title": "Draft updated", + "toast_updated_description": "Your changes to the draft were saved.", + "toast_update_failed": "Could not save changes", "card_title": "New journal entry", "fiscal_year": "Fiscal year", "fiscal_year_placeholder": "Select period", @@ -3602,6 +3624,7 @@ "import_psd2_active_warning_body": "Transactions sync automatically each night. File imports are only needed for older history or when PSD2 isn't working — otherwise duplicates may occur." }, "bookkeeping": { + "edit_draft_dialog_title": "Edit draft", "title": "Bookkeeping", "year_end": "Year-end (Årsbokslut)", "tab_journal": "Journal entries", @@ -4169,7 +4192,7 @@ "export_subtitle": "Download your bookkeeping as a SIE file or back it up to Google Drive", "tab_import": "Import", "tab_export": "Export", - "sandbox_disabled": "Import is not available in the sandbox. Create an account to import data.", + "sandbox_disabled": "Bank connections and migration from other systems require a real account and are disabled in the sandbox. File-based imports (bank files, CSV/Excel and SIE) work as usual.", "back_to_choices": "Back to choices", "psd2_title": "Connect bank", "psd2_recommended": "Recommended", diff --git a/messages/sv.json b/messages/sv.json index f4d3f11c..637bb49e 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -1228,6 +1228,9 @@ "swish_error": "Ogiltigt Swish-nummer (företagsnummer 123XXXXXXX eller mobilnummer 07XXXXXXXX)" }, "settings_invoice_form": { + "default_our_reference_label": "Standard för Vår referens", + "default_our_reference_placeholder": "T.ex. ditt namn", + "default_our_reference_help": "Förifylls automatiskt på nya fakturor. Kan ändras per faktura.", "heading": "Fakturainställningar", "prefix_label": "Fakturaprefix", "prefix_placeholder": "t.ex. F-", @@ -1238,6 +1241,7 @@ "default_notes_help": "Föreslås automatiskt vid ny faktura." }, "settings_pdf_print": { + "coming_soon": "Kommer snart", "heading": "Utskrift & PDF", "toast_save_failed": "Kunde inte spara", "ore_rounding_label": "Öresavrundning", @@ -1249,7 +1253,7 @@ "show_plusgiro_label": "Visa plusgiro", "show_plusgiro_help": "Visa plusgironummer på fakturautskrift", "show_swish_label": "Visa Swish", - "show_swish_help": "Visa Swish-nummer på fakturautskrift", + "show_swish_help": "Visa swish nummer och qr kod på faktura", "show_logo_label": "Visa logga", "show_logo_help": "Visa uppladdad logga i fakturahuvudet", "show_company_name_label": "Visa företagsnamn i faktura", @@ -1477,6 +1481,12 @@ "toast_create_failed": "Kunde inte skapa nyckel", "toast_revoked": "Nyckel återkallad", "toast_revoke_failed": "Kunde inte återkalla nyckel", + "mode_label": "Miljö", + "mode_live": "Live", + "mode_test": "Test", + "mode_live_help": "Live-nycklar arbetar mot ditt riktiga företag och dess bokföring.", + "mode_test_help": "Testnycklar simulerar varje anrop (tvingad dry-run) — du ser exakt vad som skulle hända, men inget sparas eller skickas.", + "badge_test": "Test", "revoke_dialog_title": "Återkalla API-nyckel", "revoke_dialog_description": "\"{name}\" återkallas permanent. Alla klienter som använder nyckeln slutar fungera omedelbart.", "revoke_confirm": "Återkalla", @@ -2134,6 +2144,10 @@ "cancel": "Avbryt" }, "invoice_editor": { + "row_menu_set_account": "Ange försäljningskonto", + "row_menu_remove_account": "Ta bort försäljningskonto", + "revenue_account_label": "Försäljningskonto", + "revenue_account_hint": "Lämna tomt för att härleda kontot från momssatsen. Ignoreras för omvänd skattskyldighet och export.", "ore_rounding_label": "Öresavrundning", "ore_rounding_help": "Avrunda fakturatotal till hel krona", "back": "Tillbaka", @@ -2988,6 +3002,9 @@ "send_failed_fallback": "Försök igen." }, "journal_list": { + "mode_vouchers": "Verifikat", + "mode_drafts": "Utkast", + "show_correction_chain": "Visa storno- och rättade poster", "loading": "Laddar verifikationer...", "empty_title": "Inga verifikationer", "empty_description": "Verifikationer skapas automatiskt vid fakturering och transaktionsbokföring, eller manuellt via fliken \"Ny verifikation\".", @@ -3155,6 +3172,7 @@ "current": "Aktuell" }, "journal_detail": { + "edit_draft": "Redigera", "back": "Tillbaka till bokföring", "loading": "Laddar verifikation...", "error_not_found": "Verifikation hittades inte", @@ -3219,6 +3237,10 @@ "delete_dialog_entry_body": "Verifikatet och dess kontorader tas bort. Kopplade transaktioner och fakturor behåller sina uppgifter men markeras som ej bokförda. Underlag (kvitton, dokument) behålls men avlänkas." }, "journal_form": { + "save_edit": "Spara ändringar", + "toast_updated_title": "Utkast uppdaterat", + "toast_updated_description": "Ändringarna i utkastet har sparats.", + "toast_update_failed": "Kunde inte spara ändringarna", "card_title": "Ny verifikation", "fiscal_year": "Räkenskapsår", "fiscal_year_placeholder": "Välj period", @@ -3602,6 +3624,7 @@ "import_psd2_active_warning_body": "Transaktioner synkas automatiskt varje natt. Filimport behövs bara för äldre historik eller om PSD2 inte fungerar — annars kan dubbletter uppstå." }, "bookkeeping": { + "edit_draft_dialog_title": "Redigera utkast", "title": "Bokföring", "year_end": "Årsbokslut", "tab_journal": "Verifikationer", @@ -4169,7 +4192,7 @@ "export_subtitle": "Ladda ner bokföringen som SIE-fil eller säkerhetskopia till Google Drive", "tab_import": "Importera", "tab_export": "Exportera", - "sandbox_disabled": "Import är inte tillgängligt i sandlådemiljön. Skapa ett konto för att importera data.", + "sandbox_disabled": "Bankkoppling och migrering från andra system kräver ett riktigt konto och är avstängda i sandlådan. Filbaserad import (bankfiler, CSV/Excel och SIE) fungerar som vanligt.", "back_to_choices": "Tillbaka till val", "psd2_title": "Koppla bank", "psd2_recommended": "Rekommenderat", diff --git a/supabase/migrations/20260621130500_journal_entries_list_draft_correction_filters.sql b/supabase/migrations/20260621130500_journal_entries_list_draft_correction_filters.sql new file mode 100644 index 00000000..b8f2bda6 --- /dev/null +++ b/supabase/migrations/20260621130500_journal_entries_list_draft_correction_filters.sql @@ -0,0 +1,139 @@ +-- Verifikationslista: optional draft exclusion + correction-group collapse. +-- +-- Two new params on list_fiscal_period_entries_with_related: +-- p_exclude_draft — when true, drafts are kept out of the committed +-- list (they get their own "Utkast" surface). +-- p_collapse_corrections — when true, a correction group renders as ONE row: +-- the live correction. The mechanical storno and the +-- reversed original it replaced are hidden. Nothing +-- is deleted — every voucher keeps its number and is +-- reachable via the entry detail / chain view, and +-- the UI exposes a "show all" toggle (param false). +-- +-- Adding parameters changes the function identity, so we DROP the old 9-arg +-- signature first (CREATE OR REPLACE cannot add params) — otherwise PostgREST +-- sees two overloads and fails with "Could not choose the best candidate +-- function" when older callers pass only the original 9 named args. After the +-- drop+create there is a single 11-arg function; the two new params default to +-- false, so existing callers are unaffected. + +DROP FUNCTION IF EXISTS public.list_fiscal_period_entries_with_related( + uuid, uuid, boolean, text, date, date, text, int, int +); + +CREATE FUNCTION public.list_fiscal_period_entries_with_related( + p_company_id uuid, + p_period_id uuid, + p_include_related boolean DEFAULT true, + p_status text DEFAULT NULL, + p_date_from date DEFAULT NULL, + p_date_to date DEFAULT NULL, + p_sort_date text DEFAULT 'desc', + p_limit int DEFAULT 50, + p_offset int DEFAULT 0, + p_exclude_draft boolean DEFAULT false, + p_collapse_corrections boolean DEFAULT false +) +RETURNS TABLE ( + entry jsonb, + total_count bigint +) +LANGUAGE sql +STABLE +SECURITY INVOKER +SET search_path = public, pg_temp +AS $$ + WITH period AS ( + SELECT period_start, period_end + FROM public.fiscal_periods + WHERE id = p_period_id AND company_id = p_company_id + ), + matching AS ( + SELECT je.* + FROM public.journal_entries je + CROSS JOIN period p + WHERE je.company_id = p_company_id + AND ( + je.fiscal_period_id = p_period_id + OR ( + p_include_related + AND je.source_type IN ('invoice_paid','invoice_cash_payment','credit_note') + AND EXISTS ( + SELECT 1 FROM public.invoices i + WHERE i.id = je.source_id + AND i.company_id = p_company_id + AND i.invoice_date BETWEEN p.period_start AND p.period_end + ) + ) + OR ( + p_include_related + AND je.source_type IN ('supplier_invoice_paid','supplier_invoice_cash_payment','supplier_credit_note') + AND EXISTS ( + SELECT 1 FROM public.supplier_invoices si + WHERE si.id = je.source_id + AND si.company_id = p_company_id + AND si.invoice_date BETWEEN p.period_start AND p.period_end + ) + ) + ) + AND (p_status IS NULL OR je.status = p_status) + -- Hide cancelled by default; show them only when caller asks explicitly. + AND (je.status <> 'cancelled' OR p_status = 'cancelled') + -- Drafts live on their own surface; exclude them only on the committed + -- list. Ignored when the caller asked for an explicit status (so a + -- status='draft' request is never self-cancelled) — mirrors the route's + -- direct-query path. + AND (NOT p_exclude_draft OR p_status IS NOT NULL OR je.status <> 'draft') + -- Collapse correction groups to the live correction: drop the storno and + -- the reversed original that a posted correction replaced. + AND ( + NOT p_collapse_corrections + OR ( + je.source_type <> 'storno' + AND NOT EXISTS ( + SELECT 1 FROM public.journal_entries c + WHERE c.company_id = p_company_id + AND c.source_type = 'correction' + AND c.status = 'posted' + AND c.correction_of_id = je.id + ) + ) + ) + AND (p_date_from IS NULL OR je.entry_date >= p_date_from) + AND (p_date_to IS NULL OR je.entry_date <= p_date_to) + ), + matching_with_total AS ( + SELECT m.*, COUNT(*) OVER () AS total + FROM matching m + ), + paged AS ( + SELECT * + FROM matching_with_total + ORDER BY + CASE WHEN p_sort_date = 'asc' THEN entry_date END ASC NULLS LAST, + CASE WHEN p_sort_date = 'desc' THEN entry_date END DESC NULLS LAST, + voucher_series, + voucher_number + LIMIT p_limit OFFSET p_offset + ) + SELECT + (to_jsonb(p.*) - 'total') + || jsonb_build_object( + 'lines', COALESCE( + (SELECT jsonb_agg(to_jsonb(l.*) ORDER BY l.sort_order) + FROM public.journal_entry_lines l + WHERE l.journal_entry_id = p.id), + '[]'::jsonb + ), + 'out_of_period', (p.fiscal_period_id IS DISTINCT FROM p_period_id) + ) AS entry, + p.total AS total_count + FROM paged p + ORDER BY + CASE WHEN p_sort_date = 'asc' THEN p.entry_date END ASC NULLS LAST, + CASE WHEN p_sort_date = 'desc' THEN p.entry_date END DESC NULLS LAST, + p.voucher_series, + p.voucher_number; +$$; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260621131000_backfill_fiscal_period_previous_id.sql b/supabase/migrations/20260621131000_backfill_fiscal_period_previous_id.sql new file mode 100644 index 00000000..aa374c51 --- /dev/null +++ b/supabase/migrations/20260621131000_backfill_fiscal_period_previous_id.sql @@ -0,0 +1,41 @@ +-- Backfill fiscal_periods.previous_period_id for periods created without it. +-- +-- SIE import (lib/import/sie-import.ts ensureFiscalPeriod) historically inserted +-- fiscal periods without linking previous_period_id, so multi-year imports left +-- the BFNAR 2013:2 continuity chain broken. The resultatrapport prior-period +-- comparison walks that chain to find the prior year — with it null, the +-- comparison column showed only dashes. The balansrapport was unaffected (it +-- sums prior lines via compute_prior_opening_balances, not the chain). +-- +-- This sets each period's previous_period_id to the chronologically closest +-- preceding period in the same company. Idempotent: only touches rows where the +-- link is currently NULL, so manually-chained periods are preserved and re-runs +-- are no-ops. No trigger maintains this column (enforce_opening_balance_ +-- immutability only guards opening_balance_entry_id / closing_entry_id). +-- +-- Guard: only periods that start on the 1st of a month are touched. The +-- enforce_first_of_month_for_subsequent_periods trigger fires BEFORE UPDATE and +-- re-validates period_start, rejecting any period that starts mid-month while an +-- earlier period exists (a legacy/förlängt period that is no longer the +-- chronologically first). Such a row would abort the whole set-based UPDATE. +-- They are rare and left NULL on purpose — generateResultatrapport falls back to +-- the date-adjacent prior period when previous_period_id is null, so the +-- comparison still works for them. + +UPDATE public.fiscal_periods AS target +SET previous_period_id = ( + SELECT p.id + FROM public.fiscal_periods p + WHERE p.company_id = target.company_id + AND p.period_end < target.period_start + ORDER BY p.period_end DESC + LIMIT 1 +) +WHERE target.previous_period_id IS NULL + AND EXTRACT(DAY FROM target.period_start) = 1 + AND EXISTS ( + SELECT 1 + FROM public.fiscal_periods p2 + WHERE p2.company_id = target.company_id + AND p2.period_end < target.period_start + ); diff --git a/supabase/migrations/20260621132000_add_default_our_reference.sql b/supabase/migrations/20260621132000_add_default_our_reference.sql new file mode 100644 index 00000000..0e388833 --- /dev/null +++ b/supabase/migrations/20260621132000_add_default_our_reference.sql @@ -0,0 +1,11 @@ +-- Company-level default "Vår referens" (our reference) for invoicing. +-- +-- Most companies put the same person/handläggare in "Vår referens" on every +-- invoice. Storing a default on company_settings lets the invoice editor +-- pre-fill the per-invoice our_reference field (it stays editable per invoice). +-- Nullable free text; no behavioural change until a value is set. + +ALTER TABLE public.company_settings + ADD COLUMN IF NOT EXISTS default_our_reference text; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/helpers.ts b/tests/helpers.ts index d59dcda2..eedcc691 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -506,6 +506,7 @@ export function makeCompanySettings( company_id: 'company-1', entity_type: 'enskild_firma', company_name: 'Test Firma', + default_our_reference: null, org_number: '199001011234', address_line1: 'Testgatan 1', address_line2: null, diff --git a/types/index.ts b/types/index.ts index f4267d96..64d294b0 100644 --- a/types/index.ts +++ b/types/index.ts @@ -240,6 +240,8 @@ export interface CompanySettings { next_delivery_note_number: number invoice_default_days: number invoice_default_notes: string | null + // Default "Vår referens" — pre-fills the per-invoice our_reference field. + default_our_reference: string | null // Bookkeeping lock bookkeeping_locked_through: string | null