Fix/mcp and req (#753)
* feat(api): test-mode API keys force dry-run on the v1 REST API A key created with mode='test' (prefix gnubok_sk_test_) binds to the real company, but the v1 wrapper forces dry_run on every write so nothing is persisted or sent. Mutations on endpoints that can't be simulated (dryRunSupported=false or unregistered) are refused with 403 TEST_KEY_WRITE_BLOCKED — fail-closed. Reads pass through unchanged and every test-key response carries X-Gnubok-Mode: test. Live keys are unaffected (mode defaults to 'live'). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(invoices): company default "Vår referens" + per-line sales-account override Add company_settings.default_our_reference (settings form, schema, type); the invoice editor pre-fills our_reference from it on new invoices only, never overwriting an edited draft. Separately, add an optional per-line försäljningskonto (class-3) override in the editor — left blank, the engine still derives the revenue account from the VAT rate, and reverse-charge/export lines ignore the override. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(invoices): render a Swish payment QR on invoice PDFs Build the Swish "Type C" QR payload offline (no Swish API call) and embed it as a PNG in the invoice PDF payment box when Swish display is enabled, the invoice is in SEK, and the amount is positive. Also surface the invoice number in the payment box. Wired through every PDF render path: send, mark-sent and pdf routes (both legacy and v1), the recurring-schedule sender, and the staged-send commit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(bookkeeping): draft exclusion + correction-chain collapse on verifikationslista Extend list_fiscal_period_entries_with_related with two opt-in params: p_exclude_draft (keep drafts off the committed list — they get their own surface) and p_collapse_corrections (render a correction group as the single live correction, hiding the mechanical storno and the reversed original). Both default false; nothing is deleted, every voucher keeps its number, and a "show all" toggle exposes the full chain. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reports): link multi-year SIE periods so resultatrapport shows the prior year SIE import now sets fiscal_periods.previous_period_id in both directions when creating a period, so multi-year files chain correctly regardless of #RAR order. A backfill migration repairs periods imported before this (idempotent; only touches NULL links on first-of-month periods). generateResultatrapport falls back to the date-adjacent prior period when the chain is still null, so the comparison column works for legacy data too. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(articles): hide the VAT field for non-momsregistrerade companies The article form reads company_settings.vat_registered and, when false, hides the moms field and forces vat_rate to 0 on submit — mirroring the invoice editor so a non-VAT-registered company never sets a rate it can't charge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(import): allow file-based imports in the sandbox Bank-file, CSV/Excel and SIE imports run entirely on uploaded data with no external service, so they're now reachable in the sandbox. Only the API-backed options that need live third-party credentials (PSD2 bank connection, provider migration) stay disabled. Updates the sandbox notice copy to match. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(bookkeeping): add edit draft functionality for journal entries * feat(database): add default "Vår referens" column to company_settings for invoicing * fix(tests): set SHOW_SWISH_ON_INVOICE to false in PDF template mocks * @ fix(payments): use roundOre for Swish amount formatting Replace naive Math.round(x*100)/100 with roundOre from @/lib/money to satisfy the antipattern guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> @ --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string | null>(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') && (
|
||||
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
|
||||
{entry.status === 'draft' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => setShowEdit(true)}
|
||||
disabled={!canWrite}
|
||||
title={!canWrite ? t('read_only_tooltip') : undefined}
|
||||
>
|
||||
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : <Pencil className="mr-2 h-4 w-4" />}
|
||||
{t('edit_draft')}
|
||||
</Button>
|
||||
)}
|
||||
{entry.status === 'draft' && (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -660,6 +675,19 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit draft dialog — drafts only; PATCHes the entry in place */}
|
||||
{showEdit && entry && entry.status === 'draft' && (
|
||||
<EditDraftEntryDialog
|
||||
entry={entry}
|
||||
open={showEdit}
|
||||
onOpenChange={setShowEdit}
|
||||
onUpdated={() => {
|
||||
setShowEdit(false)
|
||||
fetchData()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<ConfirmationDialog
|
||||
open={showDeleteConfirm}
|
||||
|
||||
@@ -2115,12 +2115,17 @@ export default function ImportPage() {
|
||||
// Sync mode + view from URL search params (reacts to client-side navigation changes)
|
||||
const searchParams = useSearchParams()
|
||||
useEffect(() => {
|
||||
if (isSandbox) return
|
||||
if (searchParams.get('migration')) {
|
||||
// External imports (provider migration, PSD2 bank connection) need live
|
||||
// third-party credentials, so their deep links are ignored in the sandbox.
|
||||
// Manual file-import modes (bank file, CSV/Excel, SIE) stay reachable.
|
||||
const allowedModes = isSandbox
|
||||
? ['bank', 'sie', 'csv_data']
|
||||
: ['psd2', 'bank', 'sie', 'csv_data', 'migration']
|
||||
if (!isSandbox && searchParams.get('migration')) {
|
||||
setMode('migration')
|
||||
} else {
|
||||
const modeParam = searchParams.get('mode')
|
||||
if (modeParam && ['psd2', 'bank', 'sie', 'csv_data', 'migration'].includes(modeParam)) {
|
||||
if (modeParam && allowedModes.includes(modeParam)) {
|
||||
setMode(modeParam as ImportMode)
|
||||
}
|
||||
}
|
||||
@@ -2263,19 +2268,20 @@ export default function ImportPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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. */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={isSandbox ? -1 : 0}
|
||||
aria-disabled={isSandbox}
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'group flex items-start gap-4 rounded-lg border bg-card p-5 transition-all',
|
||||
isSandbox
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'cursor-pointer hover:border-foreground/15 hover:shadow-[var(--shadow-sm)] active:scale-[0.998]'
|
||||
'cursor-pointer hover:border-foreground/15 hover:shadow-[var(--shadow-sm)] active:scale-[0.998]'
|
||||
)}
|
||||
onClick={() => { 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') } }}
|
||||
>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-foreground/[0.06]">
|
||||
<ArrowLeftRight className="h-[18px] w-[18px] text-foreground/60" />
|
||||
@@ -2299,16 +2305,13 @@ export default function ImportPage() {
|
||||
{/* 4. CSV/Excel-data (ingående balanser, kunder, leverantörer) */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={isSandbox ? -1 : 0}
|
||||
aria-disabled={isSandbox}
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'group flex items-start gap-4 rounded-lg border bg-card p-5 transition-all',
|
||||
isSandbox
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'cursor-pointer hover:border-foreground/15 hover:shadow-[var(--shadow-sm)] active:scale-[0.998]'
|
||||
'cursor-pointer hover:border-foreground/15 hover:shadow-[var(--shadow-sm)] active:scale-[0.998]'
|
||||
)}
|
||||
onClick={() => { 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') } }}
|
||||
>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-foreground/[0.06]">
|
||||
<FileSpreadsheet className="h-[18px] w-[18px] text-foreground/60" />
|
||||
@@ -2339,16 +2342,13 @@ export default function ImportPage() {
|
||||
{/* 5. Bokföringsdata (SIE) */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={isSandbox ? -1 : 0}
|
||||
aria-disabled={isSandbox}
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'group flex items-start gap-4 rounded-lg border bg-card p-5 transition-all',
|
||||
isSandbox
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'cursor-pointer hover:border-foreground/15 hover:shadow-[var(--shadow-sm)] active:scale-[0.998]'
|
||||
'cursor-pointer hover:border-foreground/15 hover:shadow-[var(--shadow-sm)] active:scale-[0.998]'
|
||||
)}
|
||||
onClick={() => { 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') } }}
|
||||
>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-foreground/[0.06]">
|
||||
<FileText className="h-[18px] w-[18px] text-foreground/60" />
|
||||
@@ -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"
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
|
||||
@@ -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<string> {
|
||||
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])
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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<string, unknown>
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string | null>(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({
|
||||
<p className="text-xs text-muted-foreground">{t('name_en_hint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Unit + price + VAT */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{/* Unit + price + VAT (moms hidden for non-momsregistrerade) */}
|
||||
<div className={`grid grid-cols-1 gap-4 ${vatRegistered ? 'sm:grid-cols-3' : 'sm:grid-cols-2'}`}>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('unit_label')}</Label>
|
||||
<Controller
|
||||
@@ -215,6 +241,7 @@ export default function ArticleForm({
|
||||
<p className="text-sm text-destructive">{errors.price_excl_vat.message}</p>
|
||||
)}
|
||||
</div>
|
||||
{vatRegistered && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('vat_rate_label')}</Label>
|
||||
<Controller
|
||||
@@ -237,6 +264,7 @@ export default function ArticleForm({
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Advanced (collapsible) */}
|
||||
|
||||
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="sm:max-w-3xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto"
|
||||
// Same guard as Ny verifikat: an accidental outside-click must not
|
||||
// discard in-progress edits.
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('edit_draft_dialog_title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<JournalEntryForm
|
||||
key={entry.id}
|
||||
bare
|
||||
editEntryId={entry.id}
|
||||
initialLines={initialLines}
|
||||
initialDate={entry.entry_date}
|
||||
initialDescription={entry.description}
|
||||
initialNotes={entry.notes ?? undefined}
|
||||
initialVoucherSeries={entry.voucher_series}
|
||||
onUpdated={onUpdated}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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<FormLine[]>(
|
||||
initialLines ?? [{ ...BLANK_LINE }, { ...BLANK_LINE }]
|
||||
)
|
||||
const [voucherSeries, setVoucherSeries] = useState('A')
|
||||
const [voucherSeries, setVoucherSeries] = useState(initialVoucherSeries ?? 'A')
|
||||
const [nextVoucherNumber, setNextVoucherNumber] = useState<number | null>(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({
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">{t('fill_balance_hint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Document attachments */}
|
||||
{!embedded && (
|
||||
{/* Document attachments — hidden when editing a draft; underlag is
|
||||
managed from the verifikat detail page (JournalEntryAttachments). */}
|
||||
{!embedded && !editEntryId && (
|
||||
<div>
|
||||
<Label className="mb-2 block">{t('attachments_label')}</Label>
|
||||
<DocumentUploadZone
|
||||
@@ -1194,34 +1241,47 @@ export default function JournalEntryForm({
|
||||
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<div className="flex gap-2">
|
||||
{!embedded && (
|
||||
{editEntryId ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setShowClearConfirm(true)}
|
||||
disabled={!hasContent || isSubmitting || isSavingDraft}
|
||||
title={t('clear_all_tooltip')}
|
||||
onClick={handleSaveEdit}
|
||||
disabled={!isBalanced || !description || !selectedPeriod || !!periodMismatch || isSubmitting || isSavingDraft || isUploading || !canWrite}
|
||||
title={!canWrite ? t('read_only_tooltip') : undefined}
|
||||
>
|
||||
<Eraser className="mr-2 h-4 w-4" />
|
||||
{t('clear_all')}
|
||||
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : isSavingDraft && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('save_edit')}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{!embedded && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setShowClearConfirm(true)}
|
||||
disabled={!hasContent || isSubmitting || isSavingDraft}
|
||||
title={t('clear_all_tooltip')}
|
||||
>
|
||||
<Eraser className="mr-2 h-4 w-4" />
|
||||
{t('clear_all')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSaveDraft}
|
||||
disabled={!isBalanced || !description || !selectedPeriod || !!periodMismatch || isSubmitting || isSavingDraft || isUploading || !canWrite}
|
||||
title={!canWrite ? t('read_only_tooltip') : t('save_draft_tooltip')}
|
||||
>
|
||||
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : isSavingDraft && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('save_draft')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleReview}
|
||||
disabled={!isBalanced || !description || !selectedPeriod || !!periodMismatch || isSubmitting || isSavingDraft || isUploading || !canWrite}
|
||||
title={!canWrite ? t('read_only_tooltip') : undefined}
|
||||
>
|
||||
{!canWrite && <Lock className="mr-2 h-4 w-4" />}
|
||||
{t('review_and_create')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSaveDraft}
|
||||
disabled={!isBalanced || !description || !selectedPeriod || !!periodMismatch || isSubmitting || isSavingDraft || isUploading || !canWrite}
|
||||
title={!canWrite ? t('read_only_tooltip') : t('save_draft_tooltip')}
|
||||
>
|
||||
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : isSavingDraft && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('save_draft')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleReview}
|
||||
disabled={!isBalanced || !description || !selectedPeriod || !!periodMismatch || isSubmitting || isSavingDraft || isUploading || !canWrite}
|
||||
title={!canWrite ? t('read_only_tooltip') : undefined}
|
||||
>
|
||||
{!canWrite && <Lock className="mr-2 h-4 w-4" />}
|
||||
{t('review_and_create')}
|
||||
</Button>
|
||||
</div>
|
||||
{(!description || !selectedPeriod || isUploading || periodMismatch || incompleteLineCount > 0 || (!isBalanced && submittableLines.length < 2)) && (
|
||||
<div className="text-xs text-muted-foreground space-y-0.5 text-right">
|
||||
|
||||
@@ -106,6 +106,13 @@ export default function JournalEntryList() {
|
||||
const [seriesFilter, setSeriesFilter] = useState<string>('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<PageSizeChoice>('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() {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reveal the storno + reversed-original rows the default view folds
|
||||
into the surviving correction (3 rows → 1). */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="show-correction-chain"
|
||||
checked={!collapseCorrections}
|
||||
onCheckedChange={(on) => { setCollapseCorrections(!on); setPage(0) }}
|
||||
/>
|
||||
<Label htmlFor="show-correction-chain" className="text-sm cursor-pointer">
|
||||
{t('show_correction_chain')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="sm:justify-between">
|
||||
@@ -733,10 +784,36 @@ export default function JournalEntryList() {
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="inline-flex rounded-md border border-border p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => switchMode('committed')}
|
||||
className={`h-7 rounded px-3 text-xs font-medium transition-colors ${listMode === 'committed' ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
{t('mode_vouchers')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => switchMode('drafts')}
|
||||
className={`inline-flex h-7 items-center gap-1.5 rounded px-3 text-xs font-medium transition-colors ${listMode === 'drafts' ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
{t('mode_drafts')}
|
||||
{draftCount > 0 && (
|
||||
<Badge variant="secondary" className="h-4 min-w-4 justify-center px-1 text-[10px] tabular-nums">
|
||||
{draftCount}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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 && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{t('scope_label')}</span>
|
||||
<button
|
||||
|
||||
@@ -45,7 +45,16 @@ export default function NewJournalEntryDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-3xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent
|
||||
className="sm:max-w-3xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto"
|
||||
// A half-typed verifikat must survive an accidental click on the
|
||||
// backdrop (easy to do across multiple windows/screens). Closing is
|
||||
// explicit — the header X or Cancel. This also stops nested popovers
|
||||
// (AccountCombobox, date pickers) and the form's own confirm dialogs
|
||||
// from collapsing the parent when they portal outside it.
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('new_entry_dialog_title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -54,9 +54,10 @@ import {
|
||||
computeDeduction,
|
||||
} from '@/lib/invoices/rot-rut-rules'
|
||||
import AccrualPeriodControl from '@/components/bookkeeping/AccrualPeriodControl'
|
||||
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import { DEFAULT_DEFERRED_REVENUE_ACCOUNT } from '@/lib/bookkeeping/accruals/account-suggestions'
|
||||
import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute'
|
||||
import type { Customer, Currency, CreateInvoiceInput, CreateCustomerInput, InvoiceDocumentType, Article, Invoice, InvoiceItem } from '@/types'
|
||||
import type { Customer, Currency, CreateInvoiceInput, CreateCustomerInput, InvoiceDocumentType, Article, Invoice, InvoiceItem, BASAccount } from '@/types'
|
||||
|
||||
const currencies: Currency[] = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']
|
||||
const units = ['st', 'tim', 'dag', 'månad', 'km', 'kg']
|
||||
@@ -212,6 +213,10 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
// Artikelregister: active articles for the line picker + which line is mid quick-create.
|
||||
const [articles, setArticles] = useState<ArticleOption[]>([])
|
||||
const [savingArticleIndex, setSavingArticleIndex] = useState<number | null>(null)
|
||||
// Class-3 (revenue) accounts for the optional per-line försäljningskonto
|
||||
// override, plus which rows currently show that picker.
|
||||
const [revenueAccounts, setRevenueAccounts] = useState<BASAccount[]>([])
|
||||
const [accountOverrideRows, setAccountOverrideRows] = useState<Set<number>>(new Set())
|
||||
// True only when the user had zero invoices when this page loaded. The
|
||||
// post-create flow uses this to offer a one-shot "upload a logo?" prompt
|
||||
// — issue #520. Self-limits: once count > 0 it stays false.
|
||||
@@ -349,6 +354,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
fetchCustomers()
|
||||
fetchDefaultNotes()
|
||||
fetchArticles()
|
||||
fetchRevenueAccounts()
|
||||
}, [company?.id])
|
||||
|
||||
async function fetchArticles() {
|
||||
@@ -362,6 +368,17 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
setArticles((data ?? []) as ArticleOption[])
|
||||
}
|
||||
|
||||
async function fetchRevenueAccounts() {
|
||||
if (!company?.id) return
|
||||
try {
|
||||
const res = await fetch('/api/bookkeeping/accounts?class=3')
|
||||
const body = await res.json()
|
||||
setRevenueAccounts((body?.data as BASAccount[]) || [])
|
||||
} catch {
|
||||
// Non-fatal: the override picker degrades to free 4-digit entry.
|
||||
}
|
||||
}
|
||||
|
||||
// Apply a chosen article's defaults onto a line. Selecting "none" detaches the
|
||||
// article link (and its account override) but keeps the typed text/price so the
|
||||
// row becomes an editable free-text line.
|
||||
@@ -431,13 +448,18 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
if (!company?.id) return
|
||||
const { data } = await supabase
|
||||
.from('company_settings')
|
||||
.select('invoice_default_notes, clearing_number, account_number, bankgiro, accounting_method, ore_rounding, logo_url, vat_registered')
|
||||
.select('invoice_default_notes, default_our_reference, clearing_number, account_number, bankgiro, accounting_method, ore_rounding, logo_url, vat_registered')
|
||||
.eq('company_id', company.id)
|
||||
.single()
|
||||
if (data?.invoice_default_notes) {
|
||||
setDefaultNotes(data.invoice_default_notes)
|
||||
setValue('notes', data.invoice_default_notes)
|
||||
}
|
||||
// Pre-fill "Vår referens" from the company default — only when creating a
|
||||
// fresh invoice, so an edited draft's own reference is never overwritten.
|
||||
if (!isEditMode && data?.default_our_reference) {
|
||||
setValue('our_reference', data.default_our_reference)
|
||||
}
|
||||
setHasBankDetails(
|
||||
!!(data?.clearing_number && data?.account_number) || !!data?.bankgiro
|
||||
)
|
||||
@@ -669,6 +691,22 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
}
|
||||
}
|
||||
|
||||
// Open/close the optional per-line försäljningskonto override. Closing clears
|
||||
// the value so the engine falls back to the VAT-rate-derived revenue account.
|
||||
function toggleAccountOverride(index: number) {
|
||||
const isOpen = accountOverrideRows.has(index) || !!watchItems[index]?.revenue_account
|
||||
if (isOpen) {
|
||||
setValue(`items.${index}.revenue_account`, null, { shouldDirty: true })
|
||||
setAccountOverrideRows((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(index)
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
setAccountOverrideRows((prev) => new Set(prev).add(index))
|
||||
}
|
||||
}
|
||||
|
||||
// Self-billing path: no review dialog, no PDF, no send — it arrives already
|
||||
// booked. POST straight to the dedicated endpoint and open the verifikat.
|
||||
async function handleSelfBilledSubmit(data: FormData) {
|
||||
@@ -1283,6 +1321,17 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{watchItems[index]?.line_type !== 'text' && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => toggleAccountOverride(index)} className="py-2">
|
||||
<Landmark className="h-4 w-4" />
|
||||
{(accountOverrideRows.has(index) || watchItems[index]?.revenue_account)
|
||||
? t('row_menu_remove_account')
|
||||
: t('row_menu_set_account')}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="py-2 text-destructive focus:text-destructive"
|
||||
@@ -1574,6 +1623,34 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Optional försäljningskonto override (engångsartikel). When
|
||||
unset the engine derives the revenue account from the VAT
|
||||
rate; reverse-charge/export lines ignore the override. */}
|
||||
{isInvoiceDoc && watchItems[index]?.line_type !== 'text' &&
|
||||
(accountOverrideRows.has(index) || watchItems[index]?.revenue_account) && (
|
||||
<div className="md:col-span-12 mt-2 md:mt-3">
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="min-w-[220px] flex-1 space-y-1 md:space-y-2">
|
||||
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">
|
||||
{t('revenue_account_label')}
|
||||
</Label>
|
||||
<Controller
|
||||
name={`items.${index}.revenue_account`}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<AccountCombobox
|
||||
value={field.value ?? ''}
|
||||
accounts={revenueAccounts}
|
||||
onChange={(v) => field.onChange(v || null)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t('revenue_account_hint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile summary row */}
|
||||
<div className="flex justify-between text-sm pt-1 border-t border-border/40 md:hidden">
|
||||
<span className="text-muted-foreground">{t('row_label', { index: index + 1 })}</span>
|
||||
|
||||
@@ -155,6 +155,7 @@ interface ApiKey {
|
||||
name: string
|
||||
scopes: string[] | null
|
||||
rate_limit_rpm: number
|
||||
mode?: 'live' | 'test'
|
||||
last_used_at: string | null
|
||||
revoked_at: string | null
|
||||
created_at: string
|
||||
@@ -252,6 +253,10 @@ export function ApiKeysPanel() {
|
||||
const [showKeyDialog, setShowKeyDialog] = useState(false)
|
||||
const [showApiKeyMethods, setShowApiKeyMethods] = useState(false)
|
||||
const [newKeyName, setNewKeyName] = useState('')
|
||||
// 'live' by default: this is the general MCP-key surface and the dominant case
|
||||
// is a key for the user's real company. 'test' is an explicit opt-in — a
|
||||
// simulation-only key that forces dry-run on every write (nothing is saved).
|
||||
const [newKeyMode, setNewKeyMode] = useState<'live' | 'test'>('live')
|
||||
const [newKeyScopes, setNewKeyScopes] = useState<Set<Scope>>(new Set(ALL_SCOPES))
|
||||
const [newKeyValue, setNewKeyValue] = useState('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
@@ -305,13 +310,21 @@ export function ApiKeysPanel() {
|
||||
body: JSON.stringify({
|
||||
name: newKeyName || t('default_key_name'),
|
||||
scopes: Array.from(newKeyScopes),
|
||||
mode: newKeyMode,
|
||||
...(hasSodConflict ? { acknowledge_sod: true } : {}),
|
||||
}),
|
||||
})
|
||||
const json = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
toast({ title: json.error, variant: 'destructive' })
|
||||
// The route returns the canonical { error: { code, message, message_en } }
|
||||
// envelope — render the message string, never the object (a React child
|
||||
// must be a string, not { code, message, ... }).
|
||||
const message =
|
||||
typeof json.error === 'string'
|
||||
? json.error
|
||||
: json.error?.message ?? t('toast_create_failed')
|
||||
toast({ title: message, variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -319,6 +332,7 @@ export function ApiKeysPanel() {
|
||||
setShowCreateDialog(false)
|
||||
setShowKeyDialog(true)
|
||||
setNewKeyName('')
|
||||
setNewKeyMode('live')
|
||||
setNewKeyScopes(new Set(ALL_SCOPES))
|
||||
fetchKeys()
|
||||
} catch {
|
||||
@@ -413,6 +427,11 @@ export function ApiKeysPanel() {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium truncate">{key.name}</p>
|
||||
{key.mode === 'test' && (
|
||||
<Badge variant="secondary" className="shrink-0 text-[10px] font-normal px-1.5 py-0">
|
||||
{t('badge_test')}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{scopeCount === ALL_SCOPES.length
|
||||
? t('all_permissions')
|
||||
@@ -547,6 +566,31 @@ export function ApiKeysPanel() {
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('mode_label')}</Label>
|
||||
<div className="inline-flex rounded-md border p-0.5" role="radiogroup" aria-label={t('mode_label')}>
|
||||
{(['live', 'test'] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={newKeyMode === m}
|
||||
onClick={() => setNewKeyMode(m)}
|
||||
className={cn(
|
||||
'rounded-[5px] px-3 py-1.5 text-xs transition-colors',
|
||||
newKeyMode === m
|
||||
? 'bg-secondary text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{t(m === 'live' ? 'mode_live' : 'mode_test')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{newKeyMode === 'test' ? t('mode_test_help') : t('mode_live_help')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -63,6 +63,19 @@ export function InvoiceSettingsForm({ settings }: InvoiceSettingsFormProps) {
|
||||
{t('default_notes_help')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_our_reference">{t('default_our_reference_label')}</Label>
|
||||
<Input
|
||||
id="default_our_reference"
|
||||
name="default_our_reference"
|
||||
placeholder={t('default_our_reference_placeholder')}
|
||||
defaultValue={settings.default_our_reference || ''}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('default_our_reference_help')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
{/* 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). */}
|
||||
<div className="flex items-center justify-between opacity-60">
|
||||
<div>
|
||||
<Label>{t('show_swish_label')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>{t('show_swish_label')}</Label>
|
||||
<Badge variant="secondary" className="text-[10px]">{t('coming_soon')}</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('show_swish_help')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.invoice_show_swish ?? false}
|
||||
onCheckedChange={(v) => saveToggle('invoice_show_swish', v)}
|
||||
/>
|
||||
<Switch checked={false} disabled aria-label={t('show_swish_label')} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"email": "test@example.com",
|
||||
"name": "API Test Kund AB",
|
||||
"customer_type": "swedish_business"
|
||||
}
|
||||
@@ -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('')),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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<P extends DynamicParams = { params: Promise<Record<str
|
||||
const idempotencyKey = request.headers.get(IDEMPOTENCY_HEADER)
|
||||
const isMutation = REQUIRES_IDEMPOTENCY.has(request.method)
|
||||
|
||||
// Test keys are simulation-only: every write is forced to dry-run so
|
||||
// nothing persists (the credential bakes in `?dry_run=true`). A mutating
|
||||
// endpoint that can't be simulated (dryRunSupported=false, or unregistered)
|
||||
// would otherwise write for real — block it outright so a test key can
|
||||
// never touch real data. Reads pass through unchanged (real data, no write).
|
||||
let forceDryRun = false
|
||||
if (auth.mode === 'test' && isMutation) {
|
||||
const endpoint = getEndpointByConcretePath(request.method, path)
|
||||
if (!endpoint || !endpoint.dryRunSupported) {
|
||||
userLog.warn('test key blocked from non-simulatable endpoint', {
|
||||
path,
|
||||
method: request.method,
|
||||
...forensic,
|
||||
})
|
||||
return await v1ErrorResponseFromCode('TEST_KEY_WRITE_BLOCKED', userLog, {
|
||||
requestId,
|
||||
details: { path, method: request.method },
|
||||
})
|
||||
}
|
||||
forceDryRun = true
|
||||
}
|
||||
|
||||
if (options.requireIdempotencyKey && isMutation && !idempotencyKey) {
|
||||
userLog.warn('missing idempotency key on mutating request')
|
||||
return await v1ErrorResponseFromCode('VALIDATION_ERROR', userLog, {
|
||||
@@ -391,8 +418,8 @@ export function withApiV1<P extends DynamicParams = { params: Promise<Record<str
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Dry-run resolution.
|
||||
const dryRun = isDryRun(workingRequest, url)
|
||||
// 8. Dry-run resolution. Test keys force it on regardless of the flag.
|
||||
const dryRun = isDryRun(workingRequest, url) || forceDryRun
|
||||
|
||||
const ctx: ApiV1Context = {
|
||||
requestId,
|
||||
@@ -411,6 +438,12 @@ export function withApiV1<P extends DynamicParams = { params: Promise<Record<str
|
||||
// 9. Invoke handler.
|
||||
const response = await handler(workingRequest, ctx, params)
|
||||
|
||||
// Signal test mode on every test-key response so integrators can see the
|
||||
// request was simulation-only without inspecting the body.
|
||||
if (ctx.mode === 'test') {
|
||||
response.headers.set('X-Gnubok-Mode', 'test')
|
||||
}
|
||||
|
||||
// 10. Persist idempotency cache (best-effort).
|
||||
if (idempotencyKey && isMutation && companyId && response.status < 500) {
|
||||
try {
|
||||
|
||||
+11
-2
@@ -291,10 +291,19 @@ export function createServiceClientNoCookies() {
|
||||
)
|
||||
}
|
||||
|
||||
export function generateApiKey(): { key: string; hash: string; prefix: string } {
|
||||
export function generateApiKey(mode: ApiKeyMode = 'live'): { key: string; hash: string; prefix: string } {
|
||||
const random = crypto.randomBytes(32).toString('base64url')
|
||||
const key = `${KEY_PREFIX}${random}`
|
||||
// Test keys carry an explicit `test_` infix so integrators can tell at a
|
||||
// glance which environment a key targets (matches the llms.txt contract:
|
||||
// `gnubok_sk_test_<random>`). 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 }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<JournalEntry> {
|
||||
// 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
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -117,6 +117,16 @@ const GENERIC: Record<string, StructuredErrorEntry> = {
|
||||
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.',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string | null> {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
<Text style={styles.paymentValue}>{company.plusgiro}</Text>
|
||||
</View>
|
||||
)}
|
||||
{company.swish && (company.invoice_show_swish ?? false) && (
|
||||
{SHOW_SWISH_ON_INVOICE && company.swish && (company.invoice_show_swish ?? false) && (
|
||||
<View style={styles.paymentRow}>
|
||||
<Text style={styles.paymentLabel}>{L.swish}</Text>
|
||||
<Text style={styles.paymentValue}>{company.swish}</Text>
|
||||
@@ -1061,16 +1073,22 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
<Text style={styles.paymentLabel}>{L.dueDate}</Text>
|
||||
<Text style={[styles.paymentValue, { fontWeight: 'bold' }]}>{formatDate(invoice.due_date)}</Text>
|
||||
</View>
|
||||
{invoice.invoice_number && (
|
||||
<View style={styles.paymentRow}>
|
||||
<Text style={styles.paymentLabel}>{L.invoiceNumber}</Text>
|
||||
<Text style={[styles.paymentValue, { fontWeight: 'bold' }]}>{invoice.invoice_number}</Text>
|
||||
</View>
|
||||
)}
|
||||
{(company.invoice_show_ocr ?? true) && (company.bankgiro || company.plusgiro) && lang === 'sv' && (
|
||||
<View style={styles.paymentRow}>
|
||||
<Text style={styles.paymentLabel}>{L.ocr}</Text>
|
||||
<Text style={[styles.paymentValue, { fontWeight: 'bold' }]}>{invoice.invoice_number ? generateOcrReference(invoice.invoice_number) : '—'}</Text>
|
||||
</View>
|
||||
)}
|
||||
{lang !== 'sv' && invoice.invoice_number && (
|
||||
<View style={styles.paymentRow}>
|
||||
<Text style={styles.paymentLabel}>{L.paymentReference}</Text>
|
||||
<Text style={[styles.paymentValue, { fontWeight: 'bold' }]}>{invoice.invoice_number}</Text>
|
||||
{swishQrDataUrl && (
|
||||
<View style={{ marginTop: 10, alignItems: 'center' }}>
|
||||
<Image src={swishQrDataUrl} style={{ width: 96, height: 96 }} />
|
||||
<Text style={[styles.paymentLabel, { marginTop: 2 }]}>{L.swishQrCaption}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<payee>;<amount>;<message>;<editmask>`.
|
||||
*
|
||||
* 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`
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+25
-2
@@ -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",
|
||||
|
||||
+25
-2
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
@@ -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
|
||||
);
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user