fix(invoices): repair send dialog fiscal-period query + editable issu… (#1066)

* fix(invoices): repair send dialog fiscal-period query + editable issuance lines

The send/mark-sent dialog queried fiscal_periods with start_date/end_date
instead of period_start/period_end; the query always 400ed, and since PR
#1023 made that fatal the dialog closed instantly, blocking mark-as-sent
and email send for everyone.

Also lets accrual companies edit the proposed journal lines before booking
(both send and mark-sent), mirroring the mark-paid editor: untouched
proposals still book via the server generator; edited lines book verbatim
with balance validated at three layers. Credit notes and periodiserade
invoices keep the read-only preview. The dialog now also respects
defer_invoice_booking (#967).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invoices): harden custom issuance-line validation per review findings

Extract the custom-line parse + balance check into a shared validator so
the send and mark-sent routes cannot drift. Reject rows carrying both
debit and credit, and 29xx interim accounts (custom lines skip accrual
schedule creation, so a 29xx balance would never be dissolved). Validate
the payload only after the invoice ownership fetch, and emit structured
log events when user-edited lines are booked or deliberately ignored, so
manual overrides are visible in audit review.

Account existence needs no route-level check: the engine already resolves
every account against the company chart and throws AccountsNotInChartError.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invoices): address CodeRabbit findings on issuance line editing

Reject malformed JSON bodies with 400 instead of silently booking
generated lines; restrict line editing to SEK invoices (custom lines
cannot carry FX metadata); round each line before the client balance
check to match the server; stop claiming a voucher was created in the
mark-sent toast for deferred-booking companies; add programmatic labels
to the editor inputs and remove-row buttons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-19 00:39:56 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 46b8e2bfea
commit 9c8e540338
12 changed files with 944 additions and 73 deletions
@@ -52,6 +52,11 @@ vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
mockCreateInvoiceJournalEntry(...args),
}))
const mockCreateSchedules = vi.fn()
vi.mock('@/lib/bookkeeping/accruals/from-invoices', () => ({
createSchedulesForCustomerInvoice: (...args: unknown[]) => mockCreateSchedules(...args),
}))
const mockIssueCreditNote = vi.fn()
vi.mock('@/lib/invoices/issue-credit-note', () => ({
issueCreditNote: (...args: unknown[]) => mockIssueCreditNote(...args),
@@ -105,6 +110,7 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null })
mockRenderToBuffer.mockResolvedValue(Buffer.from('fake-pdf'))
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
mockCreateSchedules.mockResolvedValue({ created: 0, failed: 0 })
mockIssueCreditNote.mockResolvedValue({
complete: true,
journalEntryId: 'credit-je-1',
@@ -376,6 +382,121 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
expect(mockUploadDocument).not.toHaveBeenCalled()
})
it('returns 400 when the body has malformed lines', async () => {
enqueue({ data: invoice, error: null }) // ownership fetch precedes validation
const request = createMockRequest('/api/invoices/inv-1/mark-sent', {
method: 'POST',
body: { lines: [{ account_number: 'not-an-account', debit_amount: -5 }] },
})
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
})
it('returns 400 when custom lines do not balance', async () => {
enqueue({ data: invoice, error: null })
const request = createMockRequest('/api/invoices/inv-1/mark-sent', {
method: 'POST',
body: {
lines: [
{ account_number: '1510', debit_amount: 12500, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 10000 },
],
},
})
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('INVOICE_MARK_SENT_LINES_UNBALANCED')
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
})
it('returns 400 when a row carries both debit and credit', async () => {
enqueue({ data: invoice, error: null })
const request = createMockRequest('/api/invoices/inv-1/mark-sent', {
method: 'POST',
body: {
lines: [
{ account_number: '1510', debit_amount: 100, credit_amount: 50 },
{ account_number: '3001', debit_amount: 0, credit_amount: 50 },
],
},
})
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('INVOICE_MARK_SENT_LINES_INVALID')
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
})
it('returns 400 when custom lines use a 29xx interim account', async () => {
enqueue({ data: invoice, error: null })
const request = createMockRequest('/api/invoices/inv-1/mark-sent', {
method: 'POST',
body: {
lines: [
{ account_number: '1510', debit_amount: 12500, credit_amount: 0 },
{ account_number: '2990', debit_amount: 0, credit_amount: 10000 },
{ account_number: '2611', debit_amount: 0, credit_amount: 2500 },
],
},
})
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('INVOICE_MARK_SENT_LINES_INVALID')
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
})
it('books user-edited lines verbatim via the customLines override', async () => {
enqueue({ data: invoice, error: null }) // fetch invoice
enqueue({ data: company, error: null }) // settings
enqueue({ data: [{ id: 'inv-1' }], error: null }) // status update
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-10' })
enqueue({ data: null, error: null }) // update invoice with journal_entry_id
const lines = [
{ account_number: '1510', debit_amount: 12500, credit_amount: 0 },
{ account_number: '3041', debit_amount: 0, credit_amount: 10000 },
{ account_number: '2611', debit_amount: 0, credit_amount: 2500 },
]
const request = createMockRequest('/api/invoices/inv-1/mark-sent', {
method: 'POST',
body: { lines },
})
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{
success: boolean
journal_entry_id: string | null
}>(response)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(body.journal_entry_id).toBe('je-10')
// User-edited lines book exactly as reviewed: no accrual schedules.
expect(mockCreateSchedules).not.toHaveBeenCalled()
expect(mockCreateInvoiceJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({ id: 'inv-1' }),
'enskild_firma',
customer.name,
expect.objectContaining({
customLines: [
expect.objectContaining({ account_number: '1510', debit_amount: 12500 }),
expect.objectContaining({ account_number: '3041', credit_amount: 10000 }),
expect.objectContaining({ account_number: '2611', credit_amount: 2500 }),
],
})
)
})
it('renders the archived PDF as if already sent (no UTKAST banner)', async () => {
enqueue({ data: invoice, error: null }) // fetch invoice (status: 'draft')
enqueue({ data: company, error: null }) // settings
+97 -27
View File
@@ -12,6 +12,7 @@ import {
} from '@/lib/invoices/issue-credit-note'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { parseCustomIssuanceLines } from '@/lib/invoices/issuance-custom-lines'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
import { uploadDocument } from '@/lib/core/documents/document-service'
@@ -37,9 +38,23 @@ ensureInitialized()
*/
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'invoice.mark_sent',
async (_request, { supabase, user, companyId, log, requestId }, { params }) => {
async (request, { supabase, user, companyId, log, requestId }, { params }) => {
const { id } = await params
// Optional body. Backwards-compat: callers may POST with no body. Read it
// here but validate only AFTER the ownership fetch below, so callers never
// get payload feedback for invoices outside their company.
let rawBody: unknown
const bodyText = await request.text()
if (bodyText) {
try {
rawBody = JSON.parse(bodyText)
} catch {
// Malformed JSON must not silently fall back to generated lines.
return NextResponse.json({ error: 'Ogiltig förfrågan' }, { status: 400 })
}
}
// Fetch invoice
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
@@ -61,6 +76,25 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
return errorResponseFromCode('INVOICE_MARK_SENT_INVALID_STATUS', log, { requestId })
}
const linesResult = parseCustomIssuanceLines(rawBody)
if (!linesResult.ok) {
if (linesResult.error === 'invalid_body') {
log.warn('mark-sent validation failed', { invoiceId: id })
return NextResponse.json(
{ error: 'Ogiltig förfrågan', details: linesResult.details },
{ status: 400 },
)
}
return errorResponseFromCode(
linesResult.error === 'unbalanced'
? 'INVOICE_MARK_SENT_LINES_UNBALANCED'
: 'INVOICE_MARK_SENT_LINES_INVALID',
log,
{ requestId, details: linesResult.details },
)
}
const customLines = linesResult.lines
// Assign invoice number now if this draft doesn't have one yet
try {
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
@@ -141,6 +175,19 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
let journalEntryId: string | null = null
const partialFailures: Array<{ step: string; reason: string }> = []
// Custom lines only apply where mark-sent books inline; elsewhere they are
// deliberately ignored (documented in MarkInvoiceSentSchema). Log it so the
// mismatch is visible in audit review instead of vanishing silently.
if (
customLines &&
(isCreditNote || !isRealInvoice || !booksInvoicesOnIssue(settings as CompanySettings))
) {
log.warn('mark-sent: custom lines ignored (not on accrual book-at-issue path)', {
invoiceId: id,
lineCount: customLines.length,
})
}
if (isCreditNote && originalInvoice) {
const issueResult = await issueCreditNote({
supabase,
@@ -183,37 +230,60 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
// booking); ekonomi books later via POST /api/invoices/[id]/book, like
// under kontantmetoden.
try {
const journalEntry = await createInvoiceJournalEntry(
supabase,
companyId,
user.id,
invoice as Invoice,
entityType,
invoice.customer?.name
)
if (customLines) {
// Audit trail: distinguish user-edited bookings from generated ones.
log.info('mark-sent: booking user-edited custom lines', {
invoiceId: id,
userId: user.id,
lineCount: customLines.length,
})
}
const journalEntry = customLines
? await createInvoiceJournalEntry(
supabase,
companyId,
user.id,
invoice as Invoice,
entityType,
invoice.customer?.name,
{ customLines },
)
: await createInvoiceJournalEntry(
supabase,
companyId,
user.id,
invoice as Invoice,
entityType,
invoice.customer?.name,
)
if (journalEntry) {
journalEntryId = journalEntry.id
// Periodiserade lines: create schedules + catch-up dissolutions now
// that the revenue entry exists. Failures are logged, never fatal:
// the verifikat is committed.
const accrual = await createSchedulesForCustomerInvoice(
supabase,
companyId,
user.id,
invoice as Invoice,
(invoice.items as InvoiceItem[] | null) ?? [],
journalEntry.id,
entityType,
)
if (accrual.failed > 0) {
log.error('accrual schedule creation failed on mark-sent', {
failed: accrual.failed,
})
partialFailures.push({
step: 'accrual_schedules',
reason: `${accrual.failed} periodisering(ar) kunde inte skapas`,
})
// the verifikat is committed. Skipped when the user edited the lines:
// the generated 29xx deferral may no longer exist in what was booked,
// and a schedule would then dissolve an interim balance that was
// never credited. User-edited lines book exactly as reviewed.
if (!customLines) {
const accrual = await createSchedulesForCustomerInvoice(
supabase,
companyId,
user.id,
invoice as Invoice,
(invoice.items as InvoiceItem[] | null) ?? [],
journalEntry.id,
entityType,
)
if (accrual.failed > 0) {
log.error('accrual schedule creation failed on mark-sent', {
failed: accrual.failed,
})
partialFailures.push({
step: 'accrual_schedules',
reason: `${accrual.failed} periodisering(ar) kunde inte skapas`,
})
}
}
const { error: linkError } = await supabase
@@ -71,6 +71,11 @@ vi.mock('@/lib/invoices/issue-credit-note', () => ({
issueCreditNote: (...args: unknown[]) => mockIssueCreditNote(...args),
}))
const mockCreateSchedules = vi.fn()
vi.mock('@/lib/bookkeeping/accruals/from-invoices', () => ({
createSchedulesForCustomerInvoice: (...args: unknown[]) => mockCreateSchedules(...args),
}))
// The sandbox guard issues a company_settings query at the top of the route;
// short-circuit it in tests since the queued mock-supabase is shaped for the
// route's existing fetch chain, not an extra pre-flight read.
@@ -118,6 +123,7 @@ describe('POST /api/invoices/[id]/send', () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
mockIsConfigured.mockReturnValue(true)
mockRenderToBuffer.mockResolvedValue(Buffer.from('fake-pdf'))
mockCreateSchedules.mockResolvedValue({ created: 0, failed: 0 })
mockIssueCreditNote.mockResolvedValue({
complete: true,
journalEntryId: 'credit-je-1',
@@ -606,6 +612,80 @@ describe('POST /api/invoices/[id]/send', () => {
expect((body.error as unknown as { details?: { retryable?: boolean } }).details?.retryable).toBe(true)
})
it('returns 400 on malformed lines before any email is sent', async () => {
enqueue({ data: invoice, error: null }) // ownership fetch precedes validation
const request = createMockRequest('/api/invoices/inv-1/send', {
method: 'POST',
body: { lines: [{ account_number: 'bad', debit_amount: -1 }] },
})
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('returns 400 on unbalanced lines before any email is sent', async () => {
enqueue({ data: invoice, error: null })
const request = createMockRequest('/api/invoices/inv-1/send', {
method: 'POST',
body: {
lines: [
{ account_number: '1510', debit_amount: 100, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 90 },
],
},
})
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('INVOICE_MARK_SENT_LINES_UNBALANCED')
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('books user-edited lines verbatim and skips accrual schedules', async () => {
enqueue({ data: invoice, error: null }) // fetch invoice
enqueue({ data: company, error: null }) // settings
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-lines' })
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-20' })
enqueue({ data: [{ id: 'inv-1' }], error: null }) // status flip
enqueue({ data: null, error: null }) // journal_entry_id link
const lines = [
{ account_number: '1510', debit_amount: 12500, credit_amount: 0 },
{ account_number: '3041', debit_amount: 0, credit_amount: 10000 },
{ account_number: '2611', debit_amount: 0, credit_amount: 2500 },
]
const request = createMockRequest('/api/invoices/inv-1/send', {
method: 'POST',
body: { lines },
})
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ success: boolean }>(response)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(mockSendEmail).toHaveBeenCalledTimes(1)
// User-edited lines book exactly as reviewed: no accrual schedules.
expect(mockCreateSchedules).not.toHaveBeenCalled()
expect(mockCreateInvoiceJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({ id: 'inv-1' }),
'enskild_firma',
undefined,
expect.objectContaining({
customLines: [
expect.objectContaining({ account_number: '1510', debit_amount: 12500 }),
expect.objectContaining({ account_number: '3041', credit_amount: 10000 }),
expect.objectContaining({ account_number: '2611', credit_amount: 2500 }),
],
})
)
})
it('renders the final PDF as if already sent (no UTKAST banner)', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: company, error: null })
+97 -22
View File
@@ -21,6 +21,7 @@ import {
} from '@/lib/invoices/issue-credit-note'
import { applyPaymentLinkToInvoice } from '@/lib/extensions/payment-links'
import { withRouteContext } from '@/lib/api/with-route-context'
import { parseCustomIssuanceLines } from '@/lib/invoices/issuance-custom-lines'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { guardSandbox } from '@/lib/sandbox/guard'
import { requireCapability } from '@/lib/entitlements/has-capability'
@@ -39,11 +40,26 @@ ensureInitialized()
export const POST = withRouteContext(
'invoice.send',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { user, supabase, companyId, log, requestId } = ctx
const opLog = log.child({ invoiceId: id })
// Optional body: user-edited issuance lines. Read here; validated after
// the ownership fetch below (no payload feedback for foreign invoices)
// but still long BEFORE the email leaves: after delivery the pipeline
// only degrades to PARTIAL. Same contract as mark-sent.
let rawBody: unknown
const bodyText = await request.text()
if (bodyText) {
try {
rawBody = JSON.parse(bodyText)
} catch {
// Malformed JSON must not silently fall back to generated lines.
return NextResponse.json({ error: 'Ogiltig förfrågan' }, { status: 400 })
}
}
// The sandbox must never deliver a real email to a real customer: block
// the entire send pipeline (PDF render + Resend send + status flip).
const blocked = await guardSandbox(supabase, companyId)
@@ -96,6 +112,34 @@ export const POST = withRouteContext(
})
}
const linesResult = parseCustomIssuanceLines(rawBody)
if (!linesResult.ok) {
if (linesResult.error === 'invalid_body') {
opLog.warn('send validation failed')
return NextResponse.json(
{ error: 'Ogiltig förfrågan', details: linesResult.details },
{ status: 400 },
)
}
return errorResponseFromCode(
linesResult.error === 'unbalanced'
? 'INVOICE_MARK_SENT_LINES_UNBALANCED'
: 'INVOICE_MARK_SENT_LINES_INVALID',
opLog,
{ requestId, details: linesResult.details },
)
}
const customLines = linesResult.lines
// Custom lines only apply where send books inline; elsewhere they are
// deliberately ignored (documented in MarkInvoiceSentSchema). Logged for
// audit visibility instead of vanishing silently.
if (customLines && isCreditNote) {
opLog.warn('send: custom lines ignored (credit notes book via issueCreditNote)', {
lineCount: customLines.length,
})
}
const customer = invoice.customer as Customer
if (!customer.email) {
return errorResponseFromCode('INVOICE_SEND_NO_CUSTOMER_EMAIL', opLog, {
@@ -361,15 +405,42 @@ export const POST = withRouteContext(
// #967: deferred companies send WITHOUT booking; ekonomi books later via
// POST /api/invoices/[id]/book. The invoice then legitimately sits at
// journal_entry_id = NULL until then, like under kontantmetoden.
if (
customLines &&
!isCreditNote &&
(!isRealInvoice || !booksInvoicesOnIssue(company as CompanySettings))
) {
opLog.warn('send: custom lines ignored (not on accrual book-at-issue path)', {
lineCount: customLines.length,
})
}
if (statusFlipped && !isCreditNote && isRealInvoice && booksInvoicesOnIssue(company as CompanySettings)) {
try {
const journalEntry = await createInvoiceJournalEntry(
supabase,
companyId!,
user.id,
invoice as Invoice,
(company as CompanySettings).entity_type,
)
if (customLines) {
// Audit trail: distinguish user-edited bookings from generated ones.
opLog.info('send: booking user-edited custom lines', {
userId: user.id,
lineCount: customLines.length,
})
}
const journalEntry = customLines
? await createInvoiceJournalEntry(
supabase,
companyId!,
user.id,
invoice as Invoice,
(company as CompanySettings).entity_type,
undefined,
{ customLines },
)
: await createInvoiceJournalEntry(
supabase,
companyId!,
user.id,
invoice as Invoice,
(company as CompanySettings).entity_type,
)
if (journalEntry) {
createdJournalEntryId = journalEntry.id
await supabase
@@ -380,20 +451,24 @@ export const POST = withRouteContext(
// Periodiserade lines: create their schedules + catch-up
// dissolutions now that the revenue entry exists. Failures degrade
// to PARTIAL: the entry is committed and must not be rolled back.
const accrual = await createSchedulesForCustomerInvoice(
supabase,
companyId!,
user.id,
invoice as Invoice,
items,
journalEntry.id,
(company as CompanySettings).entity_type,
)
if (accrual.failed > 0) {
partialFailures.push({
step: 'accrual_schedules',
reason: `${accrual.failed} periodisering(ar) kunde inte skapas`,
})
// Skipped for user-edited lines: the generated 29xx deferral may
// not exist in what was booked; edited lines book as reviewed.
if (!customLines) {
const accrual = await createSchedulesForCustomerInvoice(
supabase,
companyId!,
user.id,
invoice as Invoice,
items,
journalEntry.id,
(company as CompanySettings).entity_type,
)
if (accrual.failed > 0) {
partialFailures.push({
step: 'accrual_schedules',
reason: `${accrual.failed} periodisering(ar) kunde inte skapas`,
})
}
}
}
} catch (err) {
+305 -23
View File
@@ -11,17 +11,24 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
import { JournalEntryReviewContent } from '@/components/bookkeeping/JournalEntryReviewContent'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { proposeSendLines } from '@/lib/bookkeeping/propose-send-lines'
import { formatCurrency } from '@/lib/utils'
import { roundOre } from '@/lib/money'
import { createClient } from '@/lib/supabase/client'
import { getResponseErrorMessage } from '@/lib/errors/get-error-message'
import { useCompany, useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { creditNoteNeedsJournalEntry } from '@/lib/invoices/issue-credit-note'
import { Loader2, Mail, Send } from 'lucide-react'
import type { Invoice, InvoiceItem, Customer, EntityType } from '@/types'
import { itemHasAccrual } from '@/lib/bookkeeping/accruals/account-suggestions'
import { Loader2, Mail, Plus, Send, Trash2 } from 'lucide-react'
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
import type { Invoice, InvoiceItem, Customer, EntityType, BASAccount } from '@/types'
interface InvoiceWithRelations extends Invoice {
customer: Customer
@@ -54,11 +61,25 @@ export default function SendInvoiceDialog({
const isCreditRepair = isCreditNote && invoice.status === 'sent'
const [isSubmitting, setIsSubmitting] = useState(false)
const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual')
const [entityType, setEntityType] = useState<EntityType>('enskild_firma')
const [periodName, setPeriodName] = useState('')
const [isInitialized, setIsInitialized] = useState(false)
const [shouldBookOnIssue, setShouldBookOnIssue] = useState(true)
const [deferBooking, setDeferBooking] = useState(false)
const [accounts, setAccounts] = useState<BASAccount[]>([])
const [editLines, setEditLines] = useState<FormLine[]>([])
const [hasEdited, setHasEdited] = useState(false)
// The accrual book-at-issue path (both email send and manual mark-sent)
// lets the user adjust the proposed lines before booking (same editor as
// PaymentBookingDialog). Credit notes keep the read-only preview, as do
// invoices with periodiserade rows: the server generator defers those to
// 29xx and creates dissolution schedules, which user-edited lines bypass.
// SEK only: the generated path stamps FX metadata (currency, exchange rate)
// on the receivable line, which custom lines cannot carry.
const hasAccrualItems = (invoice.items ?? []).some((item) => itemHasAccrual(item))
const editable =
!isCreditNote && shouldBookOnIssue && !hasAccrualItems && invoice.currency === 'SEK'
useEffect(() => {
if (!open) {
@@ -75,15 +96,15 @@ export default function SendInvoiceDialog({
const [settingsResult, periodResult, originalResult] = await Promise.all([
supabase
.from('company_settings')
.select('accounting_method, entity_type')
.select('accounting_method, entity_type, defer_invoice_booking')
.eq('company_id', company.id)
.maybeSingle(),
supabase
.from('fiscal_periods')
.select('name')
.eq('company_id', company.id)
.lte('start_date', invoice.invoice_date)
.gte('end_date', invoice.invoice_date)
.lte('period_start', invoice.invoice_date)
.gte('period_end', invoice.invoice_date)
.maybeSingle(),
invoice.credited_invoice_id
? supabase
@@ -102,14 +123,29 @@ export default function SendInvoiceDialog({
if (cancelled) return
const method = (settingsResult.data?.accounting_method || 'accrual') as 'accrual' | 'cash'
setAccountingMethod(method)
// #967: deferred companies mark-sent WITHOUT booking; ekonomi books
// later via a separate step, so neither preview nor editor applies.
const bookOnIssue = invoice.credited_invoice_id && originalResult.data
? creditNoteNeedsJournalEntry(method, originalResult.data)
: method === 'accrual' && !settingsResult.data?.defer_invoice_booking
// Line editing needs the chart of accounts; only the accrual
// book-at-issue path renders the editor, so skip the fetch elsewhere.
let fetchedAccounts: BASAccount[] = []
if (!invoice.credited_invoice_id && bookOnIssue && !hasAccrualItems) {
const accountsRes = await fetch('/api/bookkeeping/accounts')
if (!accountsRes.ok) throw new Error(t('load_chart_failed'))
const accountsData = await accountsRes.json()
fetchedAccounts = accountsData.data || []
}
if (cancelled) return
setAccounts(fetchedAccounts)
setEntityType((settingsResult.data?.entity_type as EntityType) || 'enskild_firma')
setPeriodName(periodResult.data?.name || '')
setShouldBookOnIssue(
invoice.credited_invoice_id && originalResult.data
? creditNoteNeedsJournalEntry(method, originalResult.data)
: method === 'accrual',
)
setDeferBooking(!!settingsResult.data?.defer_invoice_booking)
setShouldBookOnIssue(bookOnIssue)
setIsInitialized(true)
} catch (err) {
if (cancelled) return
@@ -149,17 +185,76 @@ export default function SendInvoiceDialog({
})
}, [isInitialized, shouldBookOnIssue, entityType, invoice])
const { totalDebit, totalCredit } = useMemo(() => {
// Seed the editable grid from the proposal once per open; edits must not be
// clobbered by re-renders, so proposedLines is deliberately not a dependency.
useEffect(() => {
if (!open) {
setEditLines([])
setHasEdited(false)
return
}
if (isInitialized && editable) {
setEditLines(proposedLines.map((line) => ({ ...line })))
setHasEdited(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, isInitialized, editable])
const activeLines = editable ? editLines : proposedLines
const { totalDebit, totalCredit, isBalanced, hasOrphanAmounts } = useMemo(() => {
let totalDebit = 0
let totalCredit = 0
for (const line of proposedLines) {
totalDebit += parseFloat(line.debit_amount) || 0
totalCredit += parseFloat(line.credit_amount) || 0
// A row carrying an amount but no account would be silently dropped from
// the POST while staying visible in the grid; block submit instead.
let hasOrphanAmounts = false
for (const line of activeLines) {
// Round per line like the server does, so a payload the badge calls
// balanced can never be rejected by the route's rounded check.
const debit = roundOre(parseFloat(line.debit_amount) || 0)
const credit = roundOre(parseFloat(line.credit_amount) || 0)
if ((debit || credit) && !line.account_number) hasOrphanAmounts = true
totalDebit += debit
totalCredit += credit
}
return { totalDebit, totalCredit }
}, [proposedLines])
const isBalanced = Math.round((totalDebit - totalCredit) * 100) === 0 && totalDebit > 0
return { totalDebit, totalCredit, isBalanced, hasOrphanAmounts }
}, [activeLines])
const updateLine = (index: number, field: keyof FormLine, value: string) => {
setHasEdited(true)
setEditLines((prev) => {
const next = [...prev]
const updated = { ...next[index], [field]: value }
// Debit/credit exclusion: clear the other when one is entered
if (field === 'debit_amount' && value) {
updated.credit_amount = ''
} else if (field === 'credit_amount' && value) {
updated.debit_amount = ''
}
next[index] = updated
return next
})
}
const addLine = () => {
setHasEdited(true)
setEditLines((prev) => [
...prev,
{ account_number: '', debit_amount: '', credit_amount: '', line_description: '' },
])
}
const removeLine = (index: number) => {
if (editLines.length <= 2) return
setHasEdited(true)
setEditLines((prev) => prev.filter((_, i) => i !== index))
}
const handleConfirm = async () => {
if (editable && (!isBalanced || hasOrphanAmounts)) return
setIsSubmitting(true)
try {
@@ -167,7 +262,33 @@ export default function SendInvoiceDialog({
? `/api/invoices/${invoice.id}/send`
: `/api/invoices/${invoice.id}/mark-sent`
const response = await fetch(url, { method: 'POST' })
// Untouched proposal: send no body so the server generates the entry
// itself (per-item revenue accounts, dimensions, FX metadata). Only
// actual edits override the generator.
const apiLines = editable && hasEdited
? editLines
.filter((l) => l.account_number && (parseFloat(l.debit_amount) || parseFloat(l.credit_amount)))
.map((l) => ({
account_number: l.account_number,
debit_amount: parseFloat(l.debit_amount) || 0,
credit_amount: parseFloat(l.credit_amount) || 0,
line_description: l.line_description || undefined,
dimensions:
l.dimensions && Object.keys(l.dimensions).length > 0
? l.dimensions
: undefined,
}))
: undefined
const response = await fetch(url, {
method: 'POST',
...(apiLines
? {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lines: apiLines }),
}
: {}),
})
if (!response.ok) {
throw new Error(await getResponseErrorMessage(response, 'invoice', locale))
@@ -216,7 +337,7 @@ export default function SendInvoiceDialog({
? shouldBookOnIssue
? t('credit_mark_success_voucher_created')
: t('credit_mark_success_no_voucher')
: accountingMethod === 'accrual'
: shouldBookOnIssue
? t('mark_success_voucher_created')
: undefined,
})
@@ -289,7 +410,162 @@ export default function SendInvoiceDialog({
eller använd &laquo;Markera som skickad&raquo;.
</div>
)}
{showJournalPreview ? (
{showJournalPreview && editable ? (
<>
<p className="text-sm text-muted-foreground">
{t('journal_edit_intro')}
</p>
{/* Mobile card layout */}
<div className="sm:hidden space-y-3">
{editLines.map((line, index) => (
<div key={index} className="rounded-lg border bg-card p-3 space-y-2">
<div className="flex items-start gap-2">
<div className="flex-1">
<AccountCombobox
value={line.account_number}
accounts={accounts}
onChange={(val) => updateLine(index, 'account_number', val)}
/>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 p-0 min-h-[44px] min-w-[44px] shrink-0 -mr-1 -mt-1"
onClick={() => removeLine(index)}
disabled={editLines.length <= 2}
aria-label={t('remove_row')}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label
htmlFor={`send-line-${index}-debit`}
className="text-xs text-muted-foreground"
>
{t('debit_label')}
</Label>
<Input
id={`send-line-${index}-debit`}
type="number"
step="0.01"
min="0"
placeholder="0,00"
value={line.debit_amount}
onChange={(e) => updateLine(index, 'debit_amount', e.target.value)}
className="tabular-nums text-right"
inputMode="decimal"
/>
</div>
<div className="space-y-1">
<Label
htmlFor={`send-line-${index}-credit`}
className="text-xs text-muted-foreground"
>
{t('credit_label')}
</Label>
<Input
id={`send-line-${index}-credit`}
type="number"
step="0.01"
min="0"
placeholder="0,00"
value={line.credit_amount}
onChange={(e) => updateLine(index, 'credit_amount', e.target.value)}
className="tabular-nums text-right"
inputMode="decimal"
/>
</div>
</div>
</div>
))}
<Button type="button" variant="outline" size="sm" onClick={addLine} className="w-full">
<Plus className="mr-1 h-3.5 w-3.5" /> {t('add_row')}
</Button>
</div>
{/* Desktop table layout */}
<div className="hidden sm:block space-y-2">
<div className="grid grid-cols-[1fr_120px_120px_32px] gap-2 text-xs font-medium text-muted-foreground px-1">
<span>{t('account_label')}</span>
<span className="text-right">{t('debit_label')}</span>
<span className="text-right">{t('credit_label')}</span>
<span />
</div>
{editLines.map((line, index) => (
<div key={index} className="grid grid-cols-[1fr_120px_120px_32px] gap-2 items-start">
<div className="min-w-0">
<AccountCombobox
value={line.account_number}
accounts={accounts}
onChange={(val) => updateLine(index, 'account_number', val)}
/>
</div>
<Input
type="number"
step="0.01"
min="0"
placeholder="0,00"
value={line.debit_amount}
onChange={(e) => updateLine(index, 'debit_amount', e.target.value)}
className="tabular-nums text-right"
aria-label={t('debit_label')}
/>
<Input
type="number"
step="0.01"
min="0"
placeholder="0,00"
value={line.credit_amount}
onChange={(e) => updateLine(index, 'credit_amount', e.target.value)}
className="tabular-nums text-right"
aria-label={t('credit_label')}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => removeLine(index)}
disabled={editLines.length <= 2}
aria-label={t('remove_row')}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
<Button
type="button"
variant="ghost"
size="sm"
onClick={addLine}
className="text-muted-foreground"
>
<Plus className="mr-1 h-3.5 w-3.5" />
{t('add_row')}
</Button>
</div>
{/* Balance indicator */}
<div className="flex items-center justify-between border-t pt-3">
{isBalanced ? (
<Badge variant="success">{t('balanced_badge')}</Badge>
) : (
<Badge variant="destructive">
{t('unbalanced_badge', { delta: formatCurrency(Math.abs(totalDebit - totalCredit)) })}
</Badge>
)}
<div className="text-sm text-muted-foreground tabular-nums">
{formatCurrency(totalDebit)} / {formatCurrency(totalCredit)}
</div>
</div>
</>
) : showJournalPreview ? (
<>
<p className="text-sm text-muted-foreground">
{t('journal_preview_intro')}
@@ -311,7 +587,13 @@ export default function SendInvoiceDialog({
) : (
<p className="text-sm text-muted-foreground">
{!shouldBookOnIssue
? t(isCreditNote ? 'explain_credit_cash' : 'explain_cash')
? t(
isCreditNote
? 'explain_credit_cash'
: deferBooking
? 'explain_deferred'
: 'explain_cash',
)
: mode === 'email'
? t('explain_email', { email: invoice.customer.email ?? '' })
: t('explain_manual')}
@@ -331,7 +613,7 @@ export default function SendInvoiceDialog({
</Button>
<Button
onClick={handleConfirm}
disabled={isSubmitting || !isInitialized || (mode === 'email' && (isSandbox || !canEmail))}
disabled={isSubmitting || !isInitialized || (editable && (!isBalanced || hasOrphanAmounts)) || (mode === 'email' && (isSandbox || !canEmail))}
className="w-full sm:w-auto min-h-11"
title={
mode === 'email' && isSandbox
+16
View File
@@ -668,6 +668,22 @@ export const MarkInvoicePaidSchema = z.object({
force: z.boolean().optional(),
})
export const MarkInvoiceSentSchema = z.object({
// Optional user-edited issuance lines ("Markera som skickad och bokför").
// When present they replace the generated invoice entry verbatim: the route
// validates balance and books exactly these lines, and accrual schedules
// are NOT created (what the user reviewed is what books). Only honored on
// the accrual book-at-issue path; ignored for credit notes, cash-method
// and deferred-booking companies, which don't book at mark-sent.
lines: z.array(z.object({
account_number: accountNumber,
debit_amount: nonNegativeAmount.default(0),
credit_amount: nonNegativeAmount.default(0),
line_description: z.string().optional(),
dimensions: DimensionsBagSchema.optional(),
})).min(2).optional(),
})
// ============================================================
// Customer schemas
// ============================================================
+24 -1
View File
@@ -334,8 +334,15 @@ export async function createInvoiceJournalEntry(
* verifikation should read "Självfaktura <external number>" rather than
* "Kundfaktura <our number>", and the number tag must be the counterparty's
* external number because the row has no own `invoice_number`.
*
* customLines: user-edited rows from the send dialog. Booked verbatim
* (caller validates balance); line generation is skipped entirely.
*/
options?: { descriptionPrefix?: string; numberOverride?: string | null }
options?: {
descriptionPrefix?: string
numberOverride?: string | null
customLines?: CreateJournalEntryLineInput[]
}
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, invoice.invoice_date)
if (!fiscalPeriodId) {
@@ -343,6 +350,22 @@ export async function createInvoiceJournalEntry(
return null
}
if (options?.customLines && options.customLines.length > 0) {
return createJournalEntry(supabase, companyId, userId, {
fiscal_period_id: fiscalPeriodId,
entry_date: invoice.invoice_date,
description: buildInvoiceDescription(
options?.descriptionPrefix ?? 'Kundfaktura',
options?.numberOverride ?? invoice.invoice_number,
customerName,
invoice.id,
),
source_type: 'invoice_created',
source_id: invoice.id,
lines: options.customLines,
})
}
const lines: CreateJournalEntryLineInput[] = []
const isForeign = invoice.currency !== 'SEK'
const tag = options?.numberOverride ?? invoiceTag(invoice)
+10
View File
@@ -796,6 +796,16 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
message_sv: 'Fakturan ändrades av en annan begäran. Ladda om och försök igen.',
message_en: 'The invoice was changed by another request. Reload and retry.',
},
INVOICE_MARK_SENT_LINES_UNBALANCED: {
httpStatus: 400,
message_sv: 'Verifikationsraderna är inte balanserade (debet ≠ kredit).',
message_en: 'Custom journal lines do not balance.',
},
INVOICE_MARK_SENT_LINES_INVALID: {
httpStatus: 400,
message_sv: 'Verifikationsraderna kan inte användas: en rad har både debet och kredit, eller använder ett interimskonto (29xx). Använd periodisering på fakturaraden istället.',
message_en: 'Custom journal lines are invalid: a row carries both debit and credit, or uses a 29xx interim account. Use line-level periodisering instead.',
},
INVOICE_MARK_SENT_BOOK_FAILED: {
httpStatus: 500,
message_sv: 'Fakturan kunde inte bokföras och ligger kvar som utkast.',
@@ -0,0 +1,91 @@
import { describe, it, expect } from 'vitest'
import { parseCustomIssuanceLines } from '../issuance-custom-lines'
describe('parseCustomIssuanceLines', () => {
const balanced = [
{ account_number: '1510', debit_amount: 12500, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 10000 },
{ account_number: '2611', debit_amount: 0, credit_amount: 2500 },
]
it('passes through a missing body as no lines', () => {
expect(parseCustomIssuanceLines(null)).toEqual({ ok: true, lines: undefined })
expect(parseCustomIssuanceLines(undefined)).toEqual({ ok: true, lines: undefined })
})
it('accepts a body without lines', () => {
expect(parseCustomIssuanceLines({})).toEqual({ ok: true, lines: undefined })
})
it('accepts balanced lines', () => {
const result = parseCustomIssuanceLines({ lines: balanced })
expect(result.ok).toBe(true)
if (result.ok) expect(result.lines).toHaveLength(3)
})
it('rejects malformed bodies via the schema', () => {
const result = parseCustomIssuanceLines({ lines: [{ account_number: 'x', debit_amount: -1 }] })
expect(result).toMatchObject({ ok: false, error: 'invalid_body' })
})
it('rejects fewer than two lines', () => {
const result = parseCustomIssuanceLines({ lines: [balanced[0]] })
expect(result).toMatchObject({ ok: false, error: 'invalid_body' })
})
it('rejects unbalanced lines', () => {
const result = parseCustomIssuanceLines({
lines: [balanced[0], { account_number: '3001', debit_amount: 0, credit_amount: 9999 }],
})
expect(result).toMatchObject({ ok: false, error: 'unbalanced' })
})
it('rejects zero-debit entries', () => {
const result = parseCustomIssuanceLines({
lines: [
{ account_number: '1510', debit_amount: 0, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 0 },
],
})
expect(result).toMatchObject({ ok: false, error: 'unbalanced' })
})
it('rejects sub-öre payloads whose raw sums balance but rounded sums do not', () => {
const result = parseCustomIssuanceLines({
lines: [
{ account_number: '1510', debit_amount: 0.004, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0.004, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 0.008 },
],
})
expect(result).toMatchObject({ ok: false, error: 'unbalanced' })
})
it('rejects a row carrying both debit and credit', () => {
const result = parseCustomIssuanceLines({
lines: [
{ account_number: '1510', debit_amount: 100, credit_amount: 50 },
{ account_number: '3001', debit_amount: 0, credit_amount: 50 },
],
})
expect(result).toMatchObject({
ok: false,
error: 'invalid_lines',
details: { reason: 'both_sides', index: 0 },
})
})
it('rejects 29xx interim accounts', () => {
const result = parseCustomIssuanceLines({
lines: [
{ account_number: '1510', debit_amount: 12500, credit_amount: 0 },
{ account_number: '2990', debit_amount: 0, credit_amount: 12500 },
],
})
expect(result).toMatchObject({
ok: false,
error: 'invalid_lines',
details: { reason: 'accrual_interim_account', account: '2990' },
})
})
})
+83
View File
@@ -0,0 +1,83 @@
import { MarkInvoiceSentSchema } from '@/lib/api/schemas'
import { roundOre } from '@/lib/money'
/**
* Shared parse + validation for user-edited issuance lines accepted by
* POST /api/invoices/[id]/mark-sent and POST /api/invoices/[id]/send.
* One implementation so the two routes cannot drift (compliance V2.2).
*/
export interface CustomIssuanceLine {
account_number: string
debit_amount: number
credit_amount: number
line_description?: string
dimensions?: Record<string, string>
}
export type CustomIssuanceLinesResult =
| { ok: true; lines: CustomIssuanceLine[] | undefined }
| { ok: false; error: 'invalid_body'; details: unknown }
| {
ok: false
error: 'unbalanced'
details: { totalDebit: number; totalCredit: number }
}
| {
ok: false
error: 'invalid_lines'
details: { reason: 'both_sides'; index: number } | { reason: 'accrual_interim_account'; account: string }
}
/**
* Parse an already-JSON-decoded request body and validate any custom lines.
*
* Rules beyond the Zod schema:
* - A row may not carry both a debit and a credit amount (the UI enforces
* exclusion; API callers get a clean 400 instead of a nonstandard entry).
* - 29xx interim accounts (förutbetalda intäkter) are rejected: custom lines
* skip accrual schedule creation, so a 29xx balance booked here would never
* be dissolved and would sit invisible to the periodisering monitoring.
* - Per-line öre-rounded totals must balance and be positive: the engine
* rounds each line before insert, so raw sums that balance can still book
* unbalanced otherwise.
*
* Account existence is NOT checked here: the engine resolves every account
* against the company's chart of accounts and throws AccountsNotInChartError.
*/
export function parseCustomIssuanceLines(rawBody: unknown): CustomIssuanceLinesResult {
if (rawBody == null) return { ok: true, lines: undefined }
const parsed = MarkInvoiceSentSchema.safeParse(rawBody)
if (!parsed.success) {
return { ok: false, error: 'invalid_body', details: parsed.error.flatten() }
}
const lines = parsed.data.lines
if (!lines) return { ok: true, lines: undefined }
for (const [index, line] of lines.entries()) {
if (line.debit_amount > 0 && line.credit_amount > 0) {
return {
ok: false,
error: 'invalid_lines',
details: { reason: 'both_sides', index },
}
}
if (/^29\d{2}$/.test(line.account_number)) {
return {
ok: false,
error: 'invalid_lines',
details: { reason: 'accrual_interim_account', account: line.account_number },
}
}
}
const totalDebit = lines.reduce((s, l) => s + roundOre(l.debit_amount), 0)
const totalCredit = lines.reduce((s, l) => s + roundOre(l.credit_amount), 0)
if (Math.round((totalDebit - totalCredit) * 100) !== 0 || totalDebit <= 0) {
return { ok: false, error: 'unbalanced', details: { totalDebit, totalCredit } }
}
return { ok: true, lines }
}
+10
View File
@@ -3038,9 +3038,19 @@
"fiscal_period_failed": "Could not load the fiscal period",
"original_invoice_failed": "Could not load the original invoice",
"journal_preview_intro": "The following bookkeeping verifikat is created automatically:",
"journal_edit_intro": "The following bookkeeping verifikat will be created. Adjust the lines if needed before booking:",
"load_chart_failed": "Could not load the chart of accounts",
"account_label": "Account",
"debit_label": "Debit",
"credit_label": "Credit",
"add_row": "Add row",
"remove_row": "Remove row",
"balanced_badge": "Debit = Credit",
"unbalanced_badge": "Unbalanced ({delta})",
"voucher_description": "Sales invoice{numberSpace}{customerSuffix}",
"credit_voucher_description": "Credit note{numberSpace}{customerSuffix}",
"explain_cash": "Cash method: bookkeeping happens on payment, not on invoicing.",
"explain_deferred": "Register without booking: the invoice is marked as sent without a voucher. Booking happens in a separate step.",
"explain_credit_cash": "Cash method: the original invoice is unpaid, so no verifikat is created when the credit note is issued.",
"explain_email": "The invoice is sent to {email}.",
"explain_manual": "The invoice is marked as sent.",
+10
View File
@@ -3038,9 +3038,19 @@
"fiscal_period_failed": "Kunde inte ladda räkenskapsperioden",
"original_invoice_failed": "Kunde inte ladda originalfakturan",
"journal_preview_intro": "Följande bokföringsverifikation skapas automatiskt:",
"journal_edit_intro": "Följande bokföringsverifikation skapas. Justera raderna vid behov innan du bokför:",
"load_chart_failed": "Kunde inte ladda kontoplanen",
"account_label": "Konto",
"debit_label": "Debet",
"credit_label": "Kredit",
"add_row": "Lägg till rad",
"remove_row": "Ta bort rad",
"balanced_badge": "Debet = Kredit",
"unbalanced_badge": "Obalanserad ({delta})",
"voucher_description": "Försäljning faktura{numberSpace}{customerSuffix}",
"credit_voucher_description": "Kreditfaktura{numberSpace}{customerSuffix}",
"explain_cash": "Kontantmetoden: bokföring sker vid betalning, inte vid fakturering.",
"explain_deferred": "Registrera utan bokföring: fakturan markeras som skickad utan verifikation. Bokföringen görs i ett separat steg.",
"explain_credit_cash": "Kontantmetoden: originalfakturan är obetald, så ingen verifikation skapas när kreditfakturan utfärdas.",
"explain_email": "Fakturan skickas till {email}.",
"explain_manual": "Fakturan markeras som skickad.",