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
@@ -31,6 +31,7 @@ import {
|
||||
MessageSquare,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import PaymentBookingDialog from '@/components/invoices/PaymentBookingDialog'
|
||||
import type { Invoice, InvoiceItem, Customer, InvoiceStatus, InvoiceReminder, InvoiceDocumentType } from '@/types'
|
||||
|
||||
const statusConfig: Record<InvoiceStatus, { label: string; variant: 'default' | 'secondary' | 'success' | 'warning' | 'destructive'; icon: React.ElementType }> = {
|
||||
@@ -65,6 +66,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
const [creditNote, setCreditNote] = useState<Invoice | null>(null)
|
||||
const [originalInvoice, setOriginalInvoice] = useState<Invoice | null>(null)
|
||||
const [convertedFromInvoice, setConvertedFromInvoice] = useState<Invoice | null>(null)
|
||||
const [showPaymentDialog, setShowPaymentDialog] = useState(false)
|
||||
const [isConverting, setIsConverting] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isUpdating, setIsUpdating] = useState(false)
|
||||
@@ -173,15 +175,6 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
const data = await response.json()
|
||||
throw new Error(data.error || 'Kunde inte markera som skickad')
|
||||
}
|
||||
} else if (status === 'paid') {
|
||||
// Use mark-paid API for proper bookkeeping
|
||||
const response = await fetch(`/api/invoices/${invoice.id}/mark-paid`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
throw new Error(data.error || 'Kunde inte markera som betald')
|
||||
}
|
||||
} else if (status === 'cancelled') {
|
||||
// Only drafts and proformas can be cancelled directly — sent/overdue/paid
|
||||
// invoices have committed journal entries and require a credit note instead
|
||||
@@ -418,7 +411,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
</Button>
|
||||
)}
|
||||
{(invoice.status === 'sent' || invoice.status === 'overdue') && isRealInvoice && (
|
||||
<Button onClick={() => updateStatus('paid')} disabled={isUpdating}>
|
||||
<Button onClick={() => setShowPaymentDialog(true)} disabled={isUpdating}>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Markera som betald
|
||||
</Button>
|
||||
@@ -904,7 +897,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
<>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => updateStatus('paid')}
|
||||
onClick={() => setShowPaymentDialog(true)}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
@@ -931,6 +924,19 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PaymentBookingDialog
|
||||
open={showPaymentDialog}
|
||||
onOpenChange={setShowPaymentDialog}
|
||||
invoice={invoice}
|
||||
onSuccess={() => {
|
||||
fetchInvoice()
|
||||
toast({
|
||||
title: 'Betald',
|
||||
description: `Faktura ${invoice.invoice_number} har markerats som betald och bokförts`,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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