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:
co-authored by
Claude Fable 5
parent
46b8e2bfea
commit
9c8e540338
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 «Markera som skickad».
|
||||
</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
|
||||
|
||||
@@ -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
|
||||
// ============================================================
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user