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:
co-authored by
Claude Opus 4.6
parent
93413a8fd0
commit
3e82295cce
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user