Expand supplier invoice module with overdue cron job, credit note journal entries, and event emissions on approve/mark-paid/create flows. Add entity type (EF/AB) awareness to transaction categorization UI and category mapping logic. Add comprehensive tests for supplier-invoice-entries, transaction-entries, and expanded API route coverage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
206 lines
6.6 KiB
TypeScript
206 lines
6.6 KiB
TypeScript
import { createClient } from '@/lib/supabase/server'
|
|
import { NextResponse } from 'next/server'
|
|
import { eventBus } from '@/lib/events'
|
|
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
|
import { ensureInitialized } from '@/lib/init'
|
|
import { validateBody } from '@/lib/api/validate'
|
|
import { CreateSupplierInvoiceSchema } from '@/lib/api/schemas'
|
|
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
|
|
|
ensureInitialized()
|
|
|
|
export async function GET(request: Request) {
|
|
const supabase = await createClient()
|
|
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
|
|
if (!user) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
|
|
const { searchParams } = new URL(request.url)
|
|
const status = searchParams.get('status')
|
|
|
|
let query = supabase
|
|
.from('supplier_invoices')
|
|
.select('*, supplier:suppliers(id, name)')
|
|
.eq('user_id', user.id)
|
|
|
|
if (status && status !== 'all') {
|
|
if (status === 'to_pay') {
|
|
query = query.in('status', ['approved', 'overdue'])
|
|
} else {
|
|
query = query.eq('status', status)
|
|
}
|
|
}
|
|
|
|
const { data, error } = await query.order('due_date', { ascending: true })
|
|
|
|
if (error) {
|
|
return NextResponse.json({ error: error.message }, { status: 500 })
|
|
}
|
|
|
|
return NextResponse.json({ data })
|
|
}
|
|
|
|
export async function POST(request: Request) {
|
|
const supabase = await createClient()
|
|
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
|
|
if (!user) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
|
|
const validation = await validateBody(request, CreateSupplierInvoiceSchema)
|
|
if (!validation.success) return validation.response
|
|
const body = validation.data
|
|
|
|
// Validate supplier exists and belongs to user
|
|
const { data: supplier, error: supplierError } = await supabase
|
|
.from('suppliers')
|
|
.select('*')
|
|
.eq('id', body.supplier_id)
|
|
.eq('user_id', user.id)
|
|
.single()
|
|
|
|
if (supplierError || !supplier) {
|
|
return NextResponse.json({ error: 'Supplier not found' }, { status: 404 })
|
|
}
|
|
|
|
// Get next arrival number
|
|
const { data: arrivalNum, error: arrivalError } = await supabase
|
|
.rpc('get_next_arrival_number', { p_user_id: user.id })
|
|
|
|
if (arrivalError) {
|
|
return NextResponse.json({ error: 'Failed to get arrival number' }, { status: 500 })
|
|
}
|
|
|
|
// Calculate totals from items (supports both amount-based and legacy quantity*price)
|
|
const items = body.items.map((item, index) => {
|
|
const vatRate = item.vat_rate ?? 0.25
|
|
const lineTotal = item.amount != null
|
|
? Math.round(item.amount * 100) / 100
|
|
: Math.round((item.quantity ?? 1) * (item.unit_price ?? 0) * 100) / 100
|
|
const vatAmount = Math.round(lineTotal * vatRate * 100) / 100
|
|
return {
|
|
sort_order: index,
|
|
description: item.description,
|
|
quantity: item.amount != null ? 1 : (item.quantity ?? 1),
|
|
unit: item.amount != null ? 'st' : (item.unit || 'st'),
|
|
unit_price: item.amount != null ? lineTotal : (item.unit_price ?? 0),
|
|
line_total: lineTotal,
|
|
account_number: item.account_number,
|
|
vat_code: item.vat_code || null,
|
|
vat_rate: vatRate,
|
|
vat_amount: vatAmount,
|
|
}
|
|
})
|
|
|
|
const subtotal = items.reduce((sum, i) => sum + i.line_total, 0)
|
|
const vatAmount = items.reduce((sum, i) => sum + i.vat_amount, 0)
|
|
const total = Math.round((subtotal + vatAmount) * 100) / 100
|
|
|
|
const exchangeRate = body.exchange_rate || null
|
|
const subtotalSek = exchangeRate ? Math.round(subtotal * exchangeRate * 100) / 100 : null
|
|
const vatAmountSek = exchangeRate ? Math.round(vatAmount * exchangeRate * 100) / 100 : null
|
|
const totalSek = exchangeRate ? Math.round(total * exchangeRate * 100) / 100 : null
|
|
|
|
// Insert supplier invoice
|
|
const { data: invoice, error: invoiceError } = await supabase
|
|
.from('supplier_invoices')
|
|
.insert({
|
|
user_id: user.id,
|
|
supplier_id: body.supplier_id,
|
|
arrival_number: arrivalNum,
|
|
supplier_invoice_number: body.supplier_invoice_number,
|
|
invoice_date: body.invoice_date,
|
|
due_date: body.due_date,
|
|
delivery_date: body.delivery_date || null,
|
|
status: 'registered',
|
|
currency: body.currency || 'SEK',
|
|
exchange_rate: exchangeRate,
|
|
vat_treatment: body.vat_treatment || 'standard_25',
|
|
reverse_charge: body.reverse_charge || false,
|
|
payment_reference: body.payment_reference || null,
|
|
subtotal: Math.round(subtotal * 100) / 100,
|
|
subtotal_sek: subtotalSek,
|
|
vat_amount: Math.round(vatAmount * 100) / 100,
|
|
vat_amount_sek: vatAmountSek,
|
|
total: Math.round(total * 100) / 100,
|
|
total_sek: totalSek,
|
|
remaining_amount: Math.round(total * 100) / 100,
|
|
notes: body.notes || null,
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (invoiceError || !invoice) {
|
|
return NextResponse.json({ error: invoiceError?.message || 'Failed to create invoice' }, { status: 500 })
|
|
}
|
|
|
|
// Insert line items
|
|
const itemInserts = items.map((item) => ({
|
|
supplier_invoice_id: invoice.id,
|
|
...item,
|
|
}))
|
|
|
|
const { error: itemsError } = await supabase
|
|
.from('supplier_invoice_items')
|
|
.insert(itemInserts)
|
|
|
|
if (itemsError) {
|
|
// Clean up invoice on items failure
|
|
await supabase.from('supplier_invoices').delete().eq('id', invoice.id)
|
|
return NextResponse.json({ error: itemsError.message }, { status: 500 })
|
|
}
|
|
|
|
// Accrual method: create registration journal entry
|
|
const { data: settings } = await supabase
|
|
.from('company_settings')
|
|
.select('accounting_method')
|
|
.eq('user_id', user.id)
|
|
.single()
|
|
|
|
const accountingMethod = settings?.accounting_method || 'accrual'
|
|
let registrationJournalEntryId: string | null = null
|
|
|
|
if (accountingMethod === 'accrual') {
|
|
try {
|
|
const journalEntry = await createSupplierInvoiceRegistrationEntry(
|
|
supabase,
|
|
user.id,
|
|
invoice as SupplierInvoice,
|
|
items as SupplierInvoiceItem[],
|
|
supplier.supplier_type
|
|
)
|
|
if (journalEntry) {
|
|
registrationJournalEntryId = journalEntry.id
|
|
await supabase
|
|
.from('supplier_invoices')
|
|
.update({ registration_journal_entry_id: journalEntry.id })
|
|
.eq('id', invoice.id)
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to create registration journal entry:', err)
|
|
}
|
|
}
|
|
|
|
try {
|
|
await eventBus.emit({
|
|
type: 'supplier_invoice.registered',
|
|
payload: { supplierInvoice: invoice as SupplierInvoice, userId: user.id },
|
|
})
|
|
} catch {
|
|
// Non-blocking — event emission failure should not affect the response
|
|
}
|
|
|
|
return NextResponse.json({
|
|
data: {
|
|
...invoice,
|
|
items: itemInserts,
|
|
registration_journal_entry_id: registrationJournalEntryId,
|
|
},
|
|
})
|
|
}
|