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) {
|
||||
|
||||
Reference in New Issue
Block a user