feat: semi-manual invoice payment booking dialog (#37)

* fix: include reversed entries in all reports (general ledger, trial balance, VAT, SIE, NE, INK2)

Reversed entries (storno) must appear alongside their original posted entries
in reports for a complete audit trail. Previously, filtering by status='posted'
excluded them, causing discrepancies when corrections had been made.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: semi-manual invoice payment booking with editable journal lines

When marking an invoice as paid, users now see a dialog where they can:
- Choose which bank/cash account the payment goes to (1910, 1920, 1930, etc.)
- Review and edit the proposed journal entry lines before committing
- The happy path remains fast — lines are pre-filled correctly

Implementation:
- Pure proposePaymentLines() function for line computation (accrual + cash)
- PaymentBookingDialog with AccountCombobox, balance validation, date picker
- API accepts optional custom lines, falls back to auto-generation without them
- 18 tests (8 unit + 10 API) all passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address Greptile review — validation fallback, balance check, error handling

- P1: Return 400 on invalid body instead of silently falling back to
  auto-generated lines (split JSON parse from schema validation)
- P1: Add server-side balance check for custom lines before committing
  (debit must equal credit, totalDebit > 0)
- P2: Wrap PaymentBookingDialog init() in try/catch with toast on
  failure and auto-close instead of silent empty state
- Add 2 new tests: unbalanced lines → 400, invalid schema → 400

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-17 11:20:29 +01:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 93413a8fd0
commit 3e82295cce
13 changed files with 996 additions and 30 deletions
@@ -26,6 +26,15 @@ vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
mockCreateInvoiceCashEntry(...args),
}))
const mockCreateJournalEntry = vi.fn()
const mockFindFiscalPeriod = vi.fn()
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: (...args: unknown[]) =>
mockCreateJournalEntry(...args),
findFiscalPeriod: (...args: unknown[]) =>
mockFindFiscalPeriod(...args),
}))
import { POST } from '../route'
describe('POST /api/invoices/[id]/mark-paid', () => {
@@ -189,4 +198,135 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
expect(body.success).toBe(true)
expect(body.journal_entry_id).toBeNull()
})
it('uses custom lines when provided instead of auto-generating', async () => {
const invoice = makeInvoice({ id: 'inv-1', status: 'sent', total: 12500 })
// Fetch invoice
enqueue({ data: invoice, error: null })
// Update invoice status
enqueue({ data: null, error: null })
// Fetch company settings
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
mockFindFiscalPeriod.mockResolvedValue('fp-1')
mockCreateJournalEntry.mockResolvedValue({ id: 'je-custom' })
const customLines = [
{ account_number: '1920', debit_amount: 12500, credit_amount: 0, line_description: 'Betalning' },
{ account_number: '1510', debit_amount: 0, credit_amount: 12500, line_description: 'Betalning' },
]
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
method: 'POST',
body: {
payment_date: '2025-03-17',
lines: customLines,
},
})
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-custom')
// Should NOT call auto-generation functions
expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled()
expect(mockCreateInvoiceCashEntry).not.toHaveBeenCalled()
// Should call createJournalEntry directly with custom lines
expect(mockCreateJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'user-1',
expect.objectContaining({
entry_date: '2025-03-17',
source_type: 'invoice_paid',
lines: customLines,
})
)
})
it('returns 400 when custom lines are unbalanced', async () => {
const invoice = makeInvoice({ id: 'inv-1', status: 'sent', total: 12500 })
// Fetch invoice
enqueue({ data: invoice, error: null })
// Update invoice status
enqueue({ data: null, error: null })
// Fetch company settings
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
const unbalancedLines = [
{ account_number: '1920', debit_amount: 12500, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 10000 },
]
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
method: 'POST',
body: {
payment_date: '2025-03-17',
lines: unbalancedLines,
},
})
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toContain('balanserade')
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
})
it('returns 400 when body has invalid schema (e.g. bad account number)', async () => {
const invoice = makeInvoice({ id: 'inv-1', status: 'sent', total: 12500 })
// Fetch invoice
enqueue({ data: invoice, error: null })
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
method: 'POST',
body: {
payment_date: '2025-03-17',
lines: [
{ account_number: 'XXXX', debit_amount: 12500, credit_amount: 0 },
],
},
})
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
})
it('falls back to auto-generation when lines are not provided', async () => {
const customer = makeCustomer()
const invoice = makeInvoice({
id: 'inv-1',
status: 'sent',
total: 12500,
customer,
})
enqueue({ data: invoice, error: null })
enqueue({ data: null, error: null })
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-auto' })
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
method: 'POST',
body: { payment_date: '2025-03-17' },
})
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.journal_entry_id).toBe('je-auto')
expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalled()
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
})
})
+42 -9
View File
@@ -4,9 +4,10 @@ import {
createInvoicePaymentJournalEntry,
createInvoiceCashEntry,
} from '@/lib/bookkeeping/invoice-entries'
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { MarkInvoicePaidSchema } from '@/lib/api/schemas'
import { ensureInitialized } from '@/lib/init'
import type { EntityType, Invoice } from '@/types'
import type { CreateJournalEntryInput, EntityType, Invoice } from '@/types'
ensureInitialized()
@@ -56,19 +57,25 @@ export async function POST(
// Parse optional body (backward compatible — body may be empty)
let exchangeRateDifference: number | undefined
let bodyPaymentDate: string | undefined
let customLines: { account_number: string; debit_amount: number; credit_amount: number; line_description?: string }[] | undefined
let rawBody: unknown
try {
const text = await request.text()
if (text) {
const parsed = MarkInvoicePaidSchema.safeParse(JSON.parse(text))
if (parsed.success) {
exchangeRateDifference = parsed.data.exchange_rate_difference
bodyPaymentDate = parsed.data.payment_date
}
}
if (text) rawBody = JSON.parse(text)
} catch {
// No body or invalid JSON — use defaults
}
if (rawBody) {
const parsed = MarkInvoicePaidSchema.safeParse(rawBody)
if (!parsed.success) {
return NextResponse.json({ error: 'Ogiltig förfrågan', details: parsed.error.flatten() }, { status: 400 })
}
exchangeRateDifference = parsed.data.exchange_rate_difference
bodyPaymentDate = parsed.data.payment_date
customLines = parsed.data.lines
}
const now = new Date().toISOString()
const paymentDate = bodyPaymentDate || now.split('T')[0]
@@ -103,7 +110,33 @@ export async function POST(
if (isRealInvoice) {
try {
if (accountingMethod === 'accrual') {
if (customLines) {
// Server-side balance validation — never commit imbalanced entries
const totalDebit = customLines.reduce((s, l) => s + l.debit_amount, 0)
const totalCredit = customLines.reduce((s, l) => s + l.credit_amount, 0)
if (Math.round((totalDebit - totalCredit) * 100) !== 0 || totalDebit <= 0) {
return NextResponse.json(
{ error: 'Verifikationsraderna är inte balanserade (debet ≠ kredit)' },
{ status: 400 }
)
}
// User-provided lines from PaymentBookingDialog
const fiscalPeriodId = await findFiscalPeriod(supabase, user.id, paymentDate)
if (fiscalPeriodId) {
const sourceType = accountingMethod === 'accrual' ? 'invoice_paid' : 'invoice_cash_payment'
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: paymentDate,
description: `Betalning faktura ${invoice.invoice_number}`,
source_type: sourceType,
source_id: invoice.id,
lines: customLines,
}
const journalEntry = await createJournalEntry(supabase, user.id, input)
journalEntryId = journalEntry?.id ?? null
}
} else if (accountingMethod === 'accrual') {
// Faktureringsmetoden: clear receivable (Debit 1930, Credit 1510)
const journalEntry = await createInvoicePaymentJournalEntry(
supabase,